Technical Documentation

Introduction

The Web API Bridge receives Web API calls as HTTP requests, which are translated into SQL Stored Procedure calls that are then invoked on either a local or remote MySQL server.

The Web API Bridge determines the MySQL server to connect to by performing a DNS TXT entry lookup. For example, if the domain of the API server is 'api.webapibridge.org', then a TXT entry lookup is performed on 'db.api.webapibridge.org'.

api.webapibridge.org → db.api.webapibridge.org

The format of the text entry is the name of the database without any numeral version numbers, the '@' symbol, then a comma separated list of hostnames.

webapibridge@db1.webapibridge.org,db2.webapibridge.org,...

Apart from DNS entries, the only configuration required is that x509 certificates and keys used for authentication with remote MySQL servers must be stored within the 'SSL_BASE' directory specified. Additionally, any targeted MySQL databases must also implement the following stored function, which allows the Web API Bridge to discover what parameters exported stored procedures expect.

Note, as indicated by the function implementation below, only stored procedures with a security type of 'DEFINER' and that have a comment of 'EXPORT' can be called.

DROP   FUNCTION RetrieveParametersFor;
DELIMITER //
CREATE FUNCTION RetrieveParametersFor
(
    $database  CHAR(64),
    $name      CHAR(99)
)
RETURNS blob
READS SQL DATA
BEGIN

    DECLARE $ret BLOB;

    SELECT
        param_list

    INTO
        $ret

    FROM     mysql.proc
    WHERE    db            =  $database
    AND      name          =  $name
    AND      type          = 'PROCEDURE'
    AND      security_type = 'DEFINER'
    AND      comment       = 'EXPORT'
    ORDER BY modified DESC LIMIT 1;

    return $ret;

END
//
DELIMITER ;

Configuration

As, conceptually, the Web API Bridge is not an authentication or authorisation layer, the Web API Bridge connects to any MySQL databases using the credentials "public" and "public", and only requires the 'EXECUTE' priviledge. However, best practise is that any connections to remote MySQL databases should also be authorised and ecrypted using x509 certificates.

GRANT EXECUTE ON *.* TO 'public'@'<Web API Brige IP address>' IDENTIFIED BY 'public';

The Web API Bridge configuration file allows the user to override the username and password used, as well as the location that x509 certificates (and keys) are stored within.

configuration/conf.php
<?php

define( "DB_USERNAME", "public" );
define( "DB_PASSWORD", "public" );
define( "SSL_BASE",    "/etc/mysql/ssl/" );

To enable experimental code paths for specific api domains, edit the following by adding API domains to the array and mapping them to TRUE.

define( "EXPERIMENTAL", array( "api.example.com" => FALSE ) );

Underneath the 'SSL_BASE' directory should be a directory for each database server that is connected to. The name of the directory should match the fully qualified domain name of the server. Within that directory should be a client certificate file, key file, and a a server certificate authority (CA) file. For example:

/etc/mysql/ssl/db.webapibridge.org
/etc/mysql/ssl/db.webapibridge.org/client-cert.pem
/etc/mysql/ssl/db.webapibridge.org/client-key.pem
/etc/mysql/ssl/db.webapibridge.org/servera-ca.pem

Apache Configuration

For each API server domain, an Apache configuration is required to specify which domains are allowed to access the API server by setting the 'Access-Control-Allow-Origin'.

<VirtualHost *:80>

    ServerName  api.%DOMAIN%
    ServerAdmin webmaster@%DOMAIN%

    DocumentRoot %DOCUMENT_ROOT%/WebAPIBridge/latest/webapibridge/sbin 

    #   Uncomment to use specific database
    #
    #SetEnv USE_DATABASE                   <database name>@<database hostname>

    #   Uncomment to enable EXPERIMENTAL code paths
    #
    #SetEnv USE_EXPERIMENTAL               TRUE

    #
    #   Uncomment to allow /api/auth/oauth2/token return mingled sessionid/csrf token
    #
    #SetEnv USE_OAUTH2_TOKEN               TRUE

    #   Uncomment to not set server side sessionid cookie
    #
    #SetEnv USE_SESSION_COOKIE             FALSE

    #   Uncomment to only log level 0 messages
    #
    #SetEnv SHOW_LOG_TO_LEVEL              0

    #
    #   Uncomment to log request parameters
    #
    #SetEnv LOG_REQUEST_PARAMETERS         TRUE

    #
    #	Uncomment to log actual stored procedure calls and number of rows returned
    #
    #SetEnv LOG_STORED_PROCEDURE_CALL      TRUE

    #
    #   Uncomment to log error messages returned
    #
    #SetEnv LOG_ERRORS                     TRUE

    #
    #   Uncomment to override 200 response code with appropriate application error codes
    #
    #SetEnv USE_HTTP_ERROR_CODES           TRUE

    #
    #   Uncomment to set read timeout in seconds
    #
    #SetEnv USE_MYSQLI_OPT_READ_TIMEOUT    60

    #
    #   Uncomment to set read timeout in seconds
    #
    #SetEnv USE_CACHE_RESULTS_FOR_SECS     60

    <Directory %DOCUMENT_ROOT%/WebAPIBridge/latest/webapibridge/sbin/>
        php_value auto_prepend_file "%DOCUMENT_ROOT%/WebAPIBridge/latest/webapibridge/configuration/conf.php"
    </Directory>

    <Directory %DOCUMENT_ROOT%/WebAPIBridge/latest/webapibridge/sbin/>
        RewriteEngine On
        RewriteBase /
        RewriteCond %{REQUEST_FILENAME} !-f
        RewriteRule /* index.php
    </Directory>

    ErrorLog /var/log/apache2/api.%DOMAIN%.log

    ErrorLogFormat "[%t] [pid %P] [XForwardFor %{X-Forwarded-For}i] [Client %a] %M"

    # Possible values include: debug, info, notice, warn, error, crit,
    # alert, emerg.
    LogLevel warn

    CustomLog /var/log/apache2/access.log combined

    #ErrorDocument 404 /index.php

    Header set Access-Control-Allow-Origin      "https://%DOMAIN%"
    Header set Access-Control-Allow-Methods     "POST, GET, PUT, OPTIONS, PATCH, DELETE"
    Header set Access-Control-Allow-Credentials "true"
    Header set Access-Control-Allow-Headers     "X-Session-ID, X-CSRF-Token"
    Header set Strict-Transport-Security        "max-age=31536000"
    Header set Content-Security-Policy          "default-src 'none'; upgrade-insecure-requests;"

</VirtualHost>

Google Pub Sub configuration

The Web API Bridge is able to log API request meta-information to a Google Pub Sub topic.

Implementation

The Web API Bridge is currently implemented in PHP and is intended for use with Apache2, although it can be invoked on the PHP command-line for testing purposes. If appropriately configured, the Web API Bridge is able to log to a Google Pub Sub topic. Currently, this is facilitated by using the Google client libraries which are located in the 'dep/vendor' directory.

<?php

//require __DIR__ . '/dep/vendor/autoload.php';

The main Web API Bridge also uses the following generic dependecies whose implementations are described in the appendices.

include_once(          "CSV.php" );
include_once(      "strings.php" );
include_once(      "jstream.php" );
include_once(          "log.php" );
include_once(          "ssl.php" );
include_once(        "mysql.php" );
include_once(        "input.php" );
include_once( "experimental.php" );

The 'main' function is invoked everytime a Web API request is received. It carries out the following key steps:

  1. Deteremine environment and filter input
  2. If a tunnelling request, call the tunnelled endpoint and return its response
  3. Translate the request URI into a provisional stored procedure name
  4. Determine the domain name to use for DNS queries
  5. Retrieve target database information from DNS (or local cache)
  6. Determine the version of the database to query
  7. Map any passed HTTP parameters to stored procedure arguments
  8. Call the stored procedure
  9. If the stored procedure was an authentication request it stores the returned session id in a cookie
  10. A response object is contructed
  11. A response object is returned as either CSV of JSON
  12. Meta-information about the call is logged to a Google Pub Sub topic.
$args = isset( $argv ) ? $argv : array();

main( $args );

function main( $argv )
{
    if ( getenv( "USE_SYSLOG_PROGRAMNAME" ) ) define( "USE_SYSLOG_PROGRAMNAME", getenv( "USE_SYSLOG_PROGRAMNAME" ) );
    if ( getenv( "USE_SYSLOG_FACILITY"    ) ) define( "USE_SYSLOG_FACILITY",    getenv( "USE_SYSLOG_FACILITY"    ) );

    if ( defined( "USE_SYSLOG_PROGRAMNAME" ) && defined( "USE_SYSLOG_FACILITY" ) )
    {
        openlog( USE_SYSLOG_PROGRAMNAME, LOG_PID, NeoLog_DecodeFacility( USE_SYSLOG_FACILITY ) );
    }

    switch( $_SERVER['REQUEST_METHOD'] )
    {
    case "OPTIONS":
    case "PUT":
    case "DELETE":
        NeoLog( "Ignoring unsupported method " . $_SERVER['REQUEST_METHOD'] . ":" . $_SERVER["REDIRECT_URL"] . "?" . urldecode( file_get_contents( 'php://input') ) );
        break;

    default:
        $start = microtime( TRUE );
        $delta = 0;
        $use_buffered = ( "TRUE" == getenv( "USE_BUFFERED" ) );

        if
        (
            !string_has_prefix( $_SERVER["REDIRECT_URL"], "/auth/" )
            &&
            !string_has_prefix( $_SERVER["REDIRECT_URL"], "/api/"  )
            &&
            !string_has_prefix( $_SERVER["REDIRECT_URL"], "/raw/"  )
        )
        {
            NeoLog( "Ignoring request:" . $_SERVER["REDIRECT_URL"] . "?" . urldecode( file_get_contents( 'php://input') ) );
            exit();
        }
        else
        {
            $method = sprintf( "%4s", $_SERVER['REQUEST_METHOD'] );

            if ( "TRUE" == getenv( "LOG_REQUEST_PARAMETERS" ) )
            {
                $request = GetURIDecodedRequest();
            
                if ( array_key_exists( "password", $_REQUEST ) )
                {
                    $request["password"] = "[hidden]";
                }

                $encoded = http_build_query( $request );

                NeoLog( "Starting new request, $method " . $_SERVER["REDIRECT_URL"] . "?" . $encoded );
            }
            else
            {
                NeoLog( "Starting new request, $method " . $_SERVER["REDIRECT_URL"] );
            }
        }

        NeoLogIn();

        $environment = DetermineEnvironment         ( $argv                                                                         );
        $sp_name     = TranslateSPName              ( $environment["redirect_url"]                                                  );
                       CheckIfTunnelRequest         ( $sp_name,     $environment                                                    );
        $db_domain   = DetermineDatabaseLookupDomain( $environment["server_name"]                                                   );
        $db_info     = LookupDatabaseInfo           ( $db_domain                                                                    );
        $db_version  = LookupDatabaseVersion        ( $db_info                                                                      );
                       CheckIfDownloadRequest       ( $db_info,     $db_version, $sp_name, $environment                             );
        $sp_call     = MapRequestToSPCall           ( $db_info,     $db_version, $sp_name, $environment["request"]                  );
        $ret         = CallStoredProcedure          ( $db_info,     $db_version, $sp_name, $sp_call, $environment                   );
                       SetSessionIDCookie           ( $sp_name,     $ret                                                            );
        $response    = CreateResponse               ( $environment, $ret, ($delta = ceil( (microtime( TRUE ) - $start) * 1000000 )) );
                       OutputResponse               ( $sp_name,     $response, $environment["request"], $use_buffered               );
        //             LogToPubSub                  ( $environment, $db_info, $db_version, $sp_call, $response, $delta              );

        NeoLogOut();

        if ( "TRUE" == getenv( "LOG_STORED_PROCEDURE_CALL" ) )
        {
            //$count = 0;

            if ( is_a( $ret, 'Results' ) )
            {
                LogResult( $sp_name, $ret, "returned" );
            }
            else
            if ( is_a( $ret, 'EmptyResults' ) )
            {
                LogResult( $sp_name, $ret, "returned" );
            }
            else
            if ( is_a( $ret, 'ErrorResults' ) )
            {
                LogResult( $sp_name, $ret, "threw ERROR: " . $ret->errorMessage );
            }
            else
            {
                NeoLog( "Called: " . $sp_name . ": no return" );
            }
        }

        NeoLog( "Finishing:" . $_SERVER["REDIRECT_URL"] . " $delta microseconds" );
    }

    closelog();
}
function GetURIDecodedRequest()
{
    $get     = array_slice( $_REQUEST, 0, count( $_REQUEST ) );
    $payload = array();

    if ( "GET" != $_SERVER["REQUEST_METHOD"] )
    {
        $input = urldecode( file_get_contents( 'php://input' ) );

        parse_str( $input, $payload );
    }

    return array_merge( $get, $payload );
}
function LogResult( $sp_name, $ret, $verb )
{
    $count    = $ret->numRows();
    $inc      = 4000;
    $len      = strlen( $ret->sqlQuery );
    $message  = "Called: " . $ret->sqlQuery . ": $verb " . $count . " rows";
    $priority = String_Contains( $verb, "ERROR" ) ? LOG_ERR : LOG_INFO;

    if ( $len < $inc )
    {
        NeoLog( $message, $priority );
    }
    else
    {
        NeoLog( "Called: " . $sp_name . ": $verb " . $count . " rows", $priority );

        for ( $i = 0; $i < $len; $i += $inc )
        {
            NeoLog( substr( $ret->sqlQuery, $i, $inc ), $priority );
        }
    }
}

The following sections describes the implementations of these called functions in detail.

Determine Environment

function DetermineEnvironment( $argv )
{
    NeoLog( "Determining environment" );
    NeoLogIn();

    $environment = null;

    if ( "cli" != php_sapi_name() )
    {
        NeoLog( "Determining envionrment from Apache" );
        $environment = DetermineEnvironmentFromApache();
    }
    else
    {
        NeoLog( "Determining envionrment from command-line arguments" );
        $environment = DetermineEnvironmentFromArguments( $argv );
    }

    if ( ("" == $environment["server_name"]) || ("" == $environment["redirect_url"]) )
    {
        NeoLog( "Invalid environment, no SERVER_NAME or REDIRECT_URL." );
        NeoLog( "Commandline usage:" );
        NeoLog( "php index.php --server-name api.example.com --redirect-url /api/users/example/ --request '{\"param1\":\"value\",...}'" );
        exit;
    }

    if ( !defined( "DB_USERNAME" ) ) define( "DB_USERNAME", "public" );
    if ( !defined( "DB_PASSWORD" ) ) define( "DB_PASSWORD", "public" );
    if ( !defined( "SSL_BASE"    ) ) define( "SSL_BASE", "/etc/mysql/ssl/" );

    NeoLog( "Configuration: username: " . DB_USERNAME . "; password: " . DB_PASSWORD . "; ssl_base: " . SSL_BASE );

    //
    //  Setup use of CSRF Token
    //

    if ( "" != array_get( $environment['request'], "wab_csrf_token" ) )
    {
        $environment['request']["sid"]
        =
        substr( array_get( $environment['request'], "sid"            ), 0, 32 )
        .
        substr( array_get( $environment['request'], "wab_csrf_token" ), 0, 32 );
    }

    NeoLogOut();

    return $environment;
}
function DetermineEnvironmentFromApache()
{
    NeoLogIn();

    $input        = urldecode( file_get_contents( 'php://input' ) );
    $request      = GetURIDecodedRequest();
    $server_name  = "";
    $redirect_url = "";
    $requested    = "";

    if ( isset( $_SERVER ) && array_key_exists( "SERVER_NAME", $_SERVER ) )
    {
        $server_name = $_SERVER["SERVER_NAME"];
    }

    if ( isset( $_SERVER ) && array_key_exists( "REDIRECT_URL", $_SERVER ) )
    {
        $redirect_url = CanonicalisePath( $_SERVER["REDIRECT_URL"] );
    }

    if ( isset( $request ) )
    {
        NeoLog( "Request is set" );
        $request = string_has_prefix( $redirect_url, "/raw/" ) ? $request : Input::FilterInput( $request, null );
    }

    if ( "{" == substr( $input, 0, 1 ) )
    {
        if ( NULL !== json_decode( $input ) )
        {
            NeoLog( "Added JSON to request" );
            $request["json"] = $input;
        }
    }

    NeoLog( "Checking headers for API Key" );
    NeoLogIn();
    foreach( apache_request_headers() as $header => $value )
    {
        $_value = Input::Filter( $value );

        if ( "Accept" == $header && !array_key_exists( "accept", $request ) )
        {
            $request["accept"]         = $_value;
            NeoLog( "request['accept'] = $_value" );
        }
        else
        if ( "Authorization" == $header && !array_key_exists( "authorization", $request ) )
        {
            $request["authorization"]         = $_value;
            NeoLog( "request['authorization'] = $_value" );

            if ( !array_key_exists( "sid", $request ) )
            {
                $request["sid"]         = trim( str_replace( 'Bearer', '', $_value ) );
                NeoLog( "request['sid'] = $_value (Bearer)" );
            }
        }
        else
        if ( "X-Access-ID" == $header && !array_key_exists( "accessid", $request ) )
        {
            $request["accessid"]         = $_value;
            NeoLog( "request['accessid'] = $_value" );
        }
        else
        if ( "X-Api-Key" == $header && !array_key_exists( "apikey", $request ) )
        {
            $request["apikey"]         = $_value;
            NeoLog( "request['apikey'] = $_value" );
        }
        else
        if ( "X-Auth-Token" == $header && !array_key_exists( "auth_token", $request ) )
        {
            $request["auth_token"]         = $_value;
            NeoLog( "request['auth_token'] = $_value" );
        }
        else
        if ( "X-CSRF-Token" == $header && !array_key_exists( "wab_csrf_token", $request ) )
        {
            $request["wab_csrf_token"]         = $_value;
            NeoLog( "request['wab_csrf_token'] = $_value" );
        }
        else
        if ( "X-Request-ID" == $header && !array_key_exists( "requestid", $request ) )
        {
            $request["requestid"]         = $_value;
            NeoLog( "request['requestid'] = $_value" );
        }
        else
        if ( "X-Session-ID" == $header && !array_key_exists( "sid", $request ) )
        {
            $request["sid"]         = $_value;
            NeoLog( "request['sid'] = $_value" );
        }
        else
        {
            NeoLog( "Header: $header: $_value" );
        }
    }
    NeoLogOut();
    NeoLogOut();

    return array
    (
        "server_name"  => $server_name,
        "redirect_url" => $redirect_url,
        "request"      => $request
    );
}

function CanonicalisePath( $constant )
{
    if ( '.php' == substr( $constant, -4 ) )
    {
        $constant = dirname( $constant );
    }

    if ( '/' != substr( $constant, -1 ) )
    {
        $constant .= "/";
    }

    return $constant;
}
function DetermineEnvironmentFromArguments( $argv )
{
    $server_name  = "";
    $redirect_url = "";
    $request      = "";

    $next_is_server_name  = 0;
    $next_is_redirect_url = 0;
    $next_is_request      = 0;
    foreach( $argv as $arg )
    {
        if ( $next_is_server_name )
        {
            $server_name         = $arg;
            $next_is_server_name = 0;
        }
        else
        if ( $next_is_redirect_url )
        {
            $redirect_url         = $arg;
            $next_is_redirect_url = 0;
        }
        else
        if ( $next_is_request )
        {
            $request         = json_decode( $arg, TRUE );
            $next_is_request = 0;
        }
        else
        {
            switch ( $arg )
            {
            case "--server-name":
                $next_is_server_name = 1;
                break;

            case "--redirect-url":
                $next_is_redirect_url = 1;
                break;

            case "--request":
                $next_is_request = 1;
                break;
            }
        }
    }

    if ( !$request ) $request = array();

    return array
    (
        "server_name"  => $server_name,
        "redirect_url" => $redirect_url,
        "request"      => $request
    );
}

Translate stored procedure name

The stored procedure to be called is a simple mapping from the URL.

function TranslateSPName( $redirect_url )
{
    NeoLog( "Translating stored procedure name from url" );

    $bits = explode( '/', $redirect_url );

    if ( "" == $bits[0] ) $bits = array_slice( $bits, 1 );

    switch ( $bits[0] )
    {
    case 'api':
        $sp_name = trim( implode( '_', array_slice( $bits, 1 ) ), '_' );
        break;

    case 'auth':
        $sp_name = trim( implode( '_', array_slice( $bits, 0 ) ), '_' );
        break;

    case 'download':
        $sp_name = 'download';
        break;

    case 'raw':
        $sp_name = trim( implode( '_', array_slice( $bits, 0 ) ), '_' );
        break;

    case 'tunnel':
        $sp_name = 'tunnel';
        break;

    default:
        http_response_code( 402 );
        //header( "Content-Type: text/plain" );
        //echo "This is a Web API Bridge HTTP endpoint -- see https://www.webapibridge.org";
        exit;
    }

    return $sp_name;
}

Check if tunnel request

function CheckIfTunnelRequest( $sp_name, $environment )
{
    if ( "tunnel" != $sp_name )
    {
        //
        //  This is mysteriously not being executed...
        //

        NeoLog( "Checking if tunnel request - NO" );
    }
    else
    {
        NeoLog( "Checking if tunnel request - YES" );
        NeoLogIn();
        Tunnel( $environment );
        NeoLogOut();
    }
}
function Tunnel( $environment )
{
    $request       = $environment['request'];
    $method        = array_get( $request, "wab_method"        );
    $authorization = array_get( $request, "wab_authorization" );
    $host          = array_get( $request, "wab_host"          );
    $endpoint      = array_get( $request, "wab_endpoint"      );
    $accept        = array_get( $request, "wab_accept"        );
    $content_type  = array_get( $request, "wab_content_type"  );
    $content64     = array_get( $request, "wab_content64"     );
    $headers       = array_get( $request, "wab_headers"       );

    NeoLog( "WAB tunnel debug: $host$endpoint" );

    if ( ! ($method && $host && $endpoint && $accept && $content_type) )
    {
        header( "Content-Type: text/plain" );

        echo "Error: incomplete headers"         . "\n";
        echo "+ wab_method:       $method"       . "\n";
        echo "+ wab_host:         $host"         . "\n";
        echo "+ wab_endpoint:     $endpoint"     . "\n";
        echo "+ wab_accept:       $accept"       . "\n";
        echo "+ wab_content_type: $content_type" . "\n";
        echo "+ wab_content64:    $content64"    . "\n";
    }
    else
    {
        $curl         = SetupCURL( $method, $authorization, $host, $endpoint, $content_type, $content64, $accept, $headers );
        $response     = curl_exec ( $curl );
        $err          = curl_error( $curl );

        if ( $err )
        {
            header( "Content-Type: text/plain" );
            echo $err;
        }
        else
        {
            header( "Content-Type: $accept" );
            echo $response;
        }
    }

    ob_flush();
    flush();
    exit;
}

function SetupCURL( $method, $authorization, $host, $endpoint, $content_type, $content64, $accept, $headers )
{
    $curl = curl_init();

    $verify_host = 0;
    $verify_peer = 0;
    $verify = ! string_has_suffix( $host, ".local" );

    if ( $verify )
    {
        $verify_host = 1;
        $verify_peer = 1;
    }

    $headers = array
    (
        "Accept: $accept",
        "Content-Type: $content_type",
        "Cache-Control: no-cache"
    );

    if ( "" != $authorization )
    {
        $headers[] = "Authorization: $authorization";
    }

    $url      = $host . $endpoint;
    $content  = base64_decode( $content64 );

    if ( "GET" == $method )
    {
        $url     = $url . "?" . $content;
        $content = "";
    }
    else
    {
        $payload    = array();
        $parameters = explode( "&", $content );
        foreach ( $parameters as $keyval )
        {
            $bits = explode( "=", $keyval );
            $key  = $bits[0];
            $val  = $bits[1];

            $payload[$key] = $val;
        }
        $content = http_build_query( $payload );
    }

    error_log( "WAB Tunnel: $url" );

    curl_setopt_array(
        $curl,
        array
        (
            CURLOPT_URL            => $url,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_ENCODING       => "",
            CURLOPT_MAXREDIRS      => 10,
            CURLOPT_TIMEOUT        => 30,
            CURLOPT_HTTP_VERSION   => CURL_HTTP_VERSION_1_1,
            CURLOPT_CUSTOMREQUEST  => $method,
            CURLOPT_POSTFIELDS     => $content,
            CURLOPT_HTTPHEADER     => $headers,
            CURLOPT_SSL_VERIFYHOST => 0,
            CURLOPT_SSL_VERIFYPEER => 0
        )
    );

    return $curl;
}
static function ExtractParameters( $request )
{
    $parameters = array();

    foreach( $request as $key => $value )
    {
        if ( "wab_" != substr( $key, 0, 4 ) )
        {
            $parameters[$key] = $value;
        }
    }

    return $parameters;
}

Determine database domain

In order to allow a Web API Bridge to act as an API service for arbitrary domains, the hostname (or IP address) of the database server to be connected to, as well as the name of the database instance to be interrogated, is configured using DNS text records.

The prefix 'db.' is added to the API domain called to create the domain that is queried -- for example, 'api.example.com' would become 'db.api.example.com'. If a local domain is called, such as 'api.example.com.local', the prefix 'local-db.' is added to the API domain called, and the '.local' suffix is removed, to create the domain that is queried -- for example, 'api.example.com.local' would become 'local-db.api.example.com'.

function DetermineDatabaseLookupDomain( $server_name )
{
    if ( string_has_suffix( $server_name, ".local" ) )
    {
        $db_domain = "local-db." . str_replace( '.local', '', $server_name );
    }
    else
    {
        $db_domain = "db." . $server_name;
    }

    NeoLog( "Determining database lookup domain name: $db_domain" ); 

    return $db_domain;
}

Lookup database info

function LookupDatabaseInfo( $db_domain )
{
    $db_info = null;
    $db_temp = (object) array( "dbname" => null, "hosts" => null );

    if ( ($database = getenv( "USE_DATABASE" )) )
    {
        $db_info = ExtractDBInfoFromEnvText( $database );
    }
    else
    {
        if ( function_exists( "apcu_enabled" ) && apcu_enabled() && apcu_exists( $db_domain ) )
        {
            $db_cached = apcu_fetch( $db_domain );
            $db_name   = $db_cached->dbname;
            $db_host   = $db_cached->hosts[0];

            if ( $db_name && $db_host )
            {
                NeoLog( "Looking up database info: found cached: $db_name@$db_host" );
                $db_info = $db_cached;
            }
        }

        if ( ! $db_info )
        {
            try
            {
                $records = dns_get_record( $db_domain, DNS_TXT );
                $n       = (null != $records) ? count( $records ) : 0;

                if ( 0 < $n )
                {
                    $db_dns = ExtractDBInfoFromDNSRecords( $records );
                }
                else
                {
                    $db_dns = ExtractDBInfoFromDBDomain( $db_domain );
                }

                $db_name = $db_dns->dbname;
                $db_host = $db_dns->hosts[0];

                if ( $db_name && $db_host )
                {
                    NeoLog( "Looking up database info: DNS returned $n records: $db_name@$db_host" );
                    $db_info = $db_dns;
                }

                if ( $db_info && function_exists( "apcu_enabled" ) && apcu_enabled() )
                {
                    $ttl = 60; // seconds

                    apcu_add( $db_domain, $db_info, $ttl );
                    NeoLogIn();
                    NeoLog( "Caching: added to cache: $db_domain" );
                    NeoLogOut();
                }
            }
            catch ( Exception $ex )
            {
                NeoLog( "Exception while retrieving from DNS" );
                exit;
            }
        }
    }

    NeoLogIn();
    if ( ! $db_info )
    {
        NeoLogError( "Error, Could not determine database information, exiting." );
        exit;
    }
    else
    {
        //  Following will abort if cannot retrieve database connection.
        CheckDatabaseConnection( $db_info );
    }
    NeoLogOut();

    return $db_info;
}
function ExtractDBInfoFromDNSRecords( $records )
{
    foreach ( $records as $record )
    {
        $host    = array_key_exists(    "host", $record ) ? $record[   "host"] : "";
        $class   = array_key_exists(   "class", $record ) ? $record[  "class"] : "";
        $ttl     = array_key_exists(     "ttl", $record ) ? $record[    "ttl"] : "";
        $type    = array_key_exists(    "type", $record ) ? $record[   "type"] : "";
        $txt     = array_key_exists(     "txt", $record ) ? $record[    "txt"] : "";

        if ( $txt )
        {
            return ExtractDBInfoFromDNSText( $txt );
        }
    }

    return null;
}
function ExtractDBInfoFromDNSText( $txt )
{
    $bits = explode( "@", $txt );
        
    switch ( count( $bits ) )
    {
    case 2:
        $dbname = $bits[0];
        $hosts  = explode( ",", $bits[1] );
        break;
    
    case 1:
        $hosts  = explode( ",", $bits[0] );
        break;
    }

    return (object) array( "dbname" => $dbname, "hosts" => $hosts );
}
/*
 *  Important!!! Below code is different to similar code above.
 *  This function is called when the USE_DATABASE environment variable is used.
 *  This may either be: <database>,             e.g., base
 *  or                  <database>@[host1],..., e.g., base@db1.example.com,db2.example.com
 *
 *  In contrast, code above allows a DNS text record to have only hostnames.
 */
function ExtractDBInfoFromEnvText( $txt )
{
    $bits = explode( "@", $txt );
        
    switch ( count( $bits ) )
    {
    case 2:
        $dbname = $bits[0];
        $hosts  = explode( ",", $bits[1] );
        break;
    
    case 1:
        $dbname = $bits[0];
        $hosts  = array( "localhost" );
        break;
    }

    return (object) array( "dbname" => $dbname, "hosts" => $hosts );
}
function ExtractDBInfoFromDBDomain( $db_domain )
{
    $truncated = str_replace(    "www.", "", $db_domain );
    $truncated = str_replace( "ro-api-", "", $truncated );
    $truncated = str_replace( "ro-api.", "", $truncated );
    $truncated = str_replace(    "api-", "", $truncated );
    $truncated = str_replace(    "api.", "", $truncated );
    $bits      = explode( ".", $truncated );

    $n = count( $bits );

    for ( $i = 1; $i < $n; $i++ )
    {
        if ( ! IsTLD( $bits[$i], $i ) )
        {
            $dbname = $bits[$i];
            break;
        }
    }

    return (object) array( "dbname" => $dbname, "hosts" => array("localhost") );
}
function IsTLD( $domain_part, $level = 1 )
{
	switch ( $level )
	{
	case "1":
		switch ( $domain_part )
		{
		case "au":
		case "com":
		case "net";
		case "org";
		case "co":
		case "info":
		case "xyz":
		case "local":
			return true;
			
		default:
			return false;
		}
		break;

	case "2":
		switch ( $domain_part )
		{
		case "com":
		case "net";
		case "org";
		case "id":
		case "conf":
			return true;
			
		default:
			return false;
		}
		break;
	
	default:
		return false;
	}
}
function CheckDatabaseConnection( $db_info )
{
    if ( !defined( "DB_HOSTNAME" ) ) define( "DB_HOSTNAME", $db_info->hosts[0] );

    NeoLog( "Checking database connection to: " . $db_info->hosts[0] );
    NeoLogIn();

    $m = MySQLCreate();

    NeoLogOut();

    return TRUE;
}

Determine database version

function LookupDatabaseVersion( $db_info )
{
	$db_version = "";
    $db_name    = "";
    $db_hosts   = implode( ', ', $db_info->hosts );
    $db_host    = $db_info->hosts[0];

    $mysql  = MySQLCreate();
    $tuples = MySQLInfo( $mysql, "SHOW DATABASES" );

    foreach( $tuples as $tuple )
    {
        $tmp = $tuple["Database"];

        if ( string_has_prefix( $tmp, $db_info->dbname ) )
        {
            $db_name = $tmp;
        }
    }
    $db_version = $db_name ? str_replace( $db_info->dbname, "", $db_name ) : $db_version;

    if ( $db_name )
    {
        NeoLog( "Determining target database version: found: $db_name" );
    }
    else
    {
        NeoLogError( "Determining target database version: cound not find database for: " . $db_info->dbname );
        exit;
    }

    return $db_version;
}

Check if download request

function CheckIfDownloadRequest( $db_info, $db_version, $sp_name, $environment )
{
    NeoLog( "Checking if tunnel request" );

    if ( "download" != $sp_name )
    {
        //
        //  This is mysteriously not being executed...
        //

        NeoLog( "Checking if download request - NO" );
    }
    else
    {
        NeoLog( "Performing download" );

        NeoLogIn();
        Download( $db_info, $db_version, $environment );
        NeoLogOut();
    }
}
function Download( $db_info, $db_version, $environment )
{
    $request = $environment['request'];
    $db      = $db_info->dbname . $db_version;
    $sid     = array_get( $request, "sid"   );
    $token   = array_get( $request, "token" );

    if ( $token )
    {
        $file = Retrieve( $db, $sid, $token );

        if ( $file )
        {
            $name    = $file->filename;
            $type    = $file->filetype;
            $size    = $file->filesize;
            $base64  = $file->base64;
            $content = base64_decode( $base64 );

            header( "Content-Type: application/octet-stream"                    );
            header( "Content-Length: $size"                                     );
            header( "Content-Disposition: attachment; filename=\"$name\""       );
            header( "Pragma: public"                                            );
            header( "Cache-Control: must-revalidate, post-check=0, pre-check=0" );
            echo $content;
        }
        else
        {
            header( "Content-Type: text/plain" );
            echo "File not found";
        }
    }
    else
    {
        header( "Content-Type: text/plain" );
        echo "No file specified";
    }

    ob_flush();
    flush();
    exit;
}
function Retrieve( $db, $sid, $token )
{
    $sp_call = (object) array
    (
        "proc_name" => "Base_Files_Retrieve_By_Token",
        "arguments" => array( $sid, $token )
    );

    $ret = MySQLProcedure( $db, $sp_call, $procedure );

    //NeoLog( "Retrieve: CALL $procedure" );

    return array_key_exists( 0, $ret ) ? $ret[0] : null;
}

Determine Stored Procedure Call

function MapRequestToSPCall( $db_info, $db_version, $sp_name, $request )
{
    $sp_call   = null;
    $proc_name = null;
    $arguments = array();

    if ( "multiselect" != $sp_name )
    {
        NeoLog( "Determine Stored Procedure Call" );
        NeoLogIn();

        $db = $db_info->dbname . $db_version;

        //
        //  Create list of provisional sp names.
        //

        $provisional[] = $sp_name;
        if ( string_has_suffix( $sp_name, "_csv" ) )
        {
            $provisional[] = preg_replace( "/_csv\z/", "", $sp_name );
        }
        $provisional[] = $provisional[count($provisional)-1] . "_retrieve";

        //
        //  Use first procedure found that matches provisional name.
        //

        $result = null;
        foreach( $provisional as $prov )
        {
            $sql = "RetrieveParametersFor( '$db', '$prov' )";
            
            $result = MySQLFunction( $db, $sql );

            if ( null !== $result )
            {
                $proc_name = $prov;
                break;
            }
        }

        if ( !$proc_name )
        {
            NeoLog( "Could not find procedure for: " . $sp_name );
            return null;
        }
        else
        {
            $parameters = ConvertParametersToDictionary( $result );

            foreach ( $parameters as $parameter )
            {
                // Strip of leading '$' and make lowercase.
                $parameter_name = str_replace( '$', '', $parameter );
                $value          = null;

                if ( "wab_remote_address" == $parameter_name )
                {
                    $value = $_SERVER["REMOTE_ADDR"];
                }
                else
                if ( "sid" == strtolower( $parameter_name ) )
                {
                    $sid         = array_get( $request, "sid" );
                    $remote_addr = GetRemoteAddress();

                    if ( Experimental() )
                    {
                        $value = $sid . "@" . $remote_addr;
                    }
                    else
                    {
                        $value = $sid;
                    }
                }
                else
                if ( "apikey" == strtolower( $parameter_name ) )
                {
                    $value = array_get( $request, "apikey" ) . "@" . $_SERVER["REMOTE_ADDR"];
                }
                else
                if ( array_key_exists( $parameter_name, $request ) )
                {
                    $value = array_get( $request, $parameter_name );
                }
                else
                if ( array_key_exists( strtolower( $parameter_name ), $request ) )
                {
                    $value = array_get( $request, strtolower( $parameter_name ) );
                }
                else
                {
                    if ( ("PATCH" == $_SERVER["REQUEST_METHOD"]) )
                    {
                        $value = null;
                    }
                    else
                    {
                        $value = "";
                    }
                }

                $arguments[] = $value;
            }
        }

        NeoLogOut();
    }

    return (object) array( "proc_name" => $proc_name, "arguments" => $arguments );
}

Get Remote Address

function GetRemoteAddress()
{
	if ( array_key_exists( "X-Forwarded-For", $_SERVER ) && $_SERVER["X-Forwarded-For"] )
	{
		return strtok( $_SERVER["X-Forwarded-For"], "," );
	}
	else
	{
		return $_SERVER["REMOTE_ADDR"];
	}
}

Convert parameters to dictionary

function ConvertParametersToDictionary( $string )
{
    $parameters = array();
    $bits       = explode( ",", trim( $string ) );
    
    foreach ( $bits as $parameter )
    {
        $parts = explode( " ", trim( $parameter ) );
        
        if ( '_' != $parts[0][0] )
        {
            $parameters[] = $parts[0];

            if ( defined( "VERBOSE" ) && VERBOSE ) error_log( $parts[0] . " " . $parts[1] );
        }
    }
    
    return $parameters;
}

Call stored procedure

function CallStoredProcedure( $db_info, $db_version, $sp_name, $sp_call, $environment )
{
    $ret = null;

    if ( $sp_call )
    {
        NeoLog( "Call Stored Procedure" );
        NeoLogIn();

        $db = $db_info->dbname . $db_version;

        $cache_seconds = intval( getenv( "USE_CACHE_RESULTS_FOR_SECS" ) );

        if ( "multiselect" == $sp_name )
        {
            $ret = Multiselect   ( $db_info, $db_version, $environment );
        }
        else
        if ( $cache_seconds && function_exists( "apcu_enabled" ) && apcu_enabled() )
        {
            $key = $sp_call->proc_name . "(" . join( ",", $sp_call->arguments ) . ")";

            NeoLog( "Key: $key" );

            if ( apcu_exists( $key ) )
            {
                error_log( "Returning cached error value (cached for $cache_seconds seconds)" );
                $ret = apcu_fetch( $key );
            }
            else
            {
                $ret = MySQLProcedureResults( $db, $sp_call );

                if ( $ret instanceof ErrorResults )
                {
                    apcu_store( $key, $ret, $cache_seconds );
                }
            }
        }
        else
        {
            $ret = MySQLProcedureResults( $db, $sp_call );
        }

        NeoLogOut();
    }

    return $ret;
}
function Multiselect( $db_info, $db_version, $environment )
{
    $use_new        = true;

    $set            = array();
    $db             = $db_info->dbname . $db_version;
    $kinds          = explode( ',', array_get( $environment['request'], "kinds" ) );
    $sid            = array_get( $environment['request'], "sid"    );
    $filter         = array_get( $environment['request'], "filter" );

    #
    #   Create pseudo-request for select call
    #

    $entity_encoded      = array_get( $environment['request'], "json"   );
    $json                = html_entity_decode( $entity_encoded );
    $request             = (array) json_decode( $json );
    $request['filter']   = $filter;
    $request['sid']      = array_get( $environment['request'], "sid"    );

    foreach ( $kinds as $kind )
    {
        $sp_call = null;

        $bits = explode( ":", $kind );
        switch ( count( $bits ) )
        {
        case 1:
            $id          = "";
            $select_name = $bits[0];
            break;

        case 2:
            $id          = explode( ':', $kind )[0];
            $select_name = explode( ':', $kind )[1];
            break;

        default:
            break;
        }

        if ( $select_name )
        {
            $proc_name = "selects_$select_name";

            if ( $use_new )
            {
                $request['id'] = $id;
                $sp_call       = MapRequestToSPCall( $db_info, $db_version, $proc_name, $request );
                $tuples        = MySQLProcedure( $db, $sp_call, $procedure );
            }
            else
            {
                $arguments   = array();
                $arguments[] = $sid;
                $arguments[] = $id;
                $arguments[] = "";
                $arguments[] = $filter;
            
                $sp_call = (object) array
                (
                    "proc_name" => $proc_name,
                    "arguments" => $arguments
                );

                $tuples = MySQLProcedure( $db, $sp_call, $procedure );
            }

            if ( is_array( $tuples ) )
            {
                $set[] = (object) array( "id" => $id, "name" => $kind, "tuples" => $tuples, "call" => $procedure );
            }
            else
            {
                $set[] = (object) array( "id" => $id, "name" => $kind,  "error" => $tuples, "call" => $procedure );
            }
        }
    }

    return $set;
}

Set session ID cookie

function SetSessionIDCookie( $sp_name, $ret )
{
    NeoLog( "Existing sessionid: " . array_get( $_COOKIE, "sid" ) );

    NeoLog( "USE_SESSION_COOKIE: " . getenv( "USE_SESSION_COOKIE" ) );

    if ( "FALSE" != getenv( "USE_SESSION_COOKIE" ) )
    {
        $is_local = ("8443" == $_SERVER['SERVER_PORT']);

        if ( !$is_local ) $is_local = string_has_suffix( $_SERVER['SERVER_NAME'], '.test' );

        if ( "auth_login" == $sp_name )
        {
            if ( is_a( $ret, 'Results' ) )
            {
                $tuple     = $ret->getFirst(); // Retrieve first tuple.
                $sessionid = $tuple->sessionid;
                $cookie    = "Set-Cookie: sid=";
                $cookie    = $cookie . $sessionid;

                if ( $is_local )
                {
                    // SameSite is causing issues with local API server
                    // that uses self-signed certificates.

                    $cookie = $cookie . "; path=/; HttpOnly;secure";
                }
                else
                {
                    $cookie = $cookie . "; path=/; HttpOnly;secure;SameSite=strict";
                }
        
                header( $cookie );

                NeoLog( "Setting Session ID Cookie: " . $sessionid );
            }
        }
        else
        if ( "auth_logout" == $sp_name )
        {
            header( "Set-Cookie: sid=deleted; path=/; HttpOnly;secure;SameSite=strict; expires=Thu, 01 Jan 1970 00:00:00 GMT" );
     
            NeoLog( "Clearing Session ID Cookie" );
        }
    }

    if ( "TRUE" == getenv( "USE_OAUTH2_TOKEN" ) )
    {
        if ( "auth_oauth2_token" == $sp_name )
        {
            if ( is_a( $ret, 'Results' ) )
            {
                $tuple = $ret->getFirst(); // Retrieve first tuple.

                if ( $tuple && $tuple->access_token )
                {
                    $access_token = $tuple->access_token;

                    echo '{' . '"access_token"' . ':' . '"' . $access_token . '"' . '}';

                    NeoLog( "Returning OAuth Token: " . $access_token );

                    exit( 0 );
                }
            }
        }
    }
} 

CreateResponse

The response from the Web API Bridge is a hierarchical structure encoded in JSON. The outermost object contains the following members that can be used for debugging.

function CreateResponse( $environment, $ret, $microseconds)
{
    $request = $environment["request"];

    $response              = array();
    $response['URL'      ] = $environment["redirect_url"];
    $response['error'    ] = is_array( $ret ) || is_a( $ret, 'Results' ) || is_a( $ret, 'EmptyResults' ) ? "" : ( is_a( $ret, 'ErrorResults' ) ? $ret->errorMessage : $ret );
    $response['failover' ] = "FALSE";
    $response['hostname' ] = "";
    $response['message'  ] = "";
    $response['status'   ] = is_array( $ret ) || is_a( $ret, 'Results' ) || is_a( $ret, 'EmptyResults' ) ? "OK" : "ERROR";
    $response['warning'  ] = "";
    $response['target_id'] = array_get( $request, "target_id" );
    $response['submit'   ] = array_get( $request, "submit"    );
    $response['limit'    ] = array_get( $request, "limit"     );
    $response['offset'   ] = array_get( $request, "offset"    );
    $response['microsecs'] = $microseconds;
    $response['results'  ] = is_array( $ret ) || is_a( $ret, 'Results' ) ? $ret : (is_a( $ret, 'EmptyResults' ) ? array() : null);

    if ( "TRUE" == getenv( "USE_HTTP_ERROR_CODES" ) )
    {
        if ( string_contains( $response["status"], "ERROR" ) )
        {
            if ( string_contains( $response["error"], "INVALID_APIKEY" ) )
            {
                http_response_code( 403 );
            }
            else
            if ( string_contains( $response["error"], "INVALID_AUTHORISATION" ) )
            {
                http_response_code( 403 );
            }
            else
            if ( is_null( $ret ) )
            {
                http_response_code( 501 );
            }
            else
            {
                http_response_code( 400 );
            }
        }
    }

    if ( string_contains( $response["error"], "DUPLICATE_REQUEST" ) )
    {
        $response["status"] = "DUPLICATE";
    }

    if ( !array_key_exists( 'accept', $request ) ) $request['accept'] = "";

    if ( is_null( $ret ) )
    {
        $response['error'] = "Could not resolve stored procedure.";
    }

    if ( "TRUE" == getenv( "LOG_ERRORS" ) )
    {
        if ( $response['error'] )
        {
            NeoLogError( "Error: " . $response['error'] );
        }
    }

    return (object) $response;
}

Output Response

function OutputResponse( $sp_name, $response, $request, $use_buffered = false )
{
    NeoLog( "Output Response" );
    NeoLogIn();

    if ( string_has_suffix( $sp_name, "_csv" ) )
    {
        TranslateToCSV ( $response, $request );
    }
    else
    if ( $use_buffered )
    {
        TranslateToJSON( $response );
    }
    else
    {
        TranslateToJSONUnbuffered( $response );
    }

    NeoLogOut();
}

Translate to JSON

Finally works! Refer to: https:stackoverflow.com/questions/15273570/continue-processing-php-after-sending-http-response

Especially, Brian's comment regarding the need for "Content-Encoding" to be set to "none".

function TranslateToJSON( $response )
{
    NeoLog( "Translate to JSON (Buffered)" );

    if ( ob_get_contents() ) ob_end_clean();

    set_time_limit(0);
    ignore_user_abort( TRUE );

    ob_start();
    {
        \JStream::Encode( 0, $response );
        echo "\n";
        $size = ob_get_length();

        header( "Content-Length: $size"          );
        header( "Connection: close"              );
        header( "Content-Encoding: none"         );
        header( "Content-Type: application/json" );

    }
    ob_end_flush();
    ob_flush();
    flush();

    if (session_id()) session_write_close();
}
function TranslateToJSONUnbuffered( $response )
{
    NeoLog( "Translate to JSON (Unbuffered)" );

    set_time_limit(0);
    ignore_user_abort( TRUE );

   {
        header( "Connection: close"              );
        header( "Content-Encoding: none"         );
        header( "Content-Type: application/json" );

        \JStream::Encode( 0, $response );
        echo "\n";
    }
    flush();

    if (session_id()) session_write_close();
}
function TranslateToCSV( $response, $request )
{
    NeoLog( "Translate to CSV" );

    if ( "OK" == $response->status )
    {
        $content  = "";
        $ds       = ("true" == array_get( $request, "double_space" ));
        $numbered = ("true" == array_get( $request, "numbered"     ));
        $map      = GenerateMap( array_get( $request, "csv_map" ) );
        $results  = $response->results;

        header( "Content-Type: application/octet-stream"                    );
        header( "Content-Disposition: attachment;filename=\"download.csv\"" );
        header( "Pragma: public"                                            );
        header( "Cache-Control: must-revalidate, post-check=0, pre-check=0" );
        header( "Connection: close" );

        if ( $map )
        {
            echo \CSV::encode( \CSV::MapValues( $map, $results->toArray() ), $numbered, $ds );
        }
        else
        {
            \CSV::Echo( $results, $numbered, $ds );
        }

        flush();
    }
}
function TranslateToCSVOrig( $response, $request )
{
    NeoLog( "Translate to CSV" );

    NeoLog( var_export( $response, true ) );

    if ( "OK" == $response->status )
    {
        $content  = "";
        $ds       = ("true" == array_get( $request, "double_space" ));
        $numbered = ("true" == array_get( $request, "numbered"     ));
        $map      = GenerateMap( array_get( $request, "csv_map" ) );
        $results  = $response->results->toArray();

        if ( $map )
        {
            NeoLog( "Calling CSV::encode with mapped tuples." );

            $content = \CSV::encode( \CSV::MapValues( $map, $results ), $numbered, $ds );
        }
        else
        {
            NeoLog( "Calling CSV::encode with unmapped tuples." );

            $content = \CSV::encode( $results, $numbered, $ds );
        }

        $size = strlen( $content );

        if ( false )
        {
            error_log( "Content-Type: application/octet-stream" );
            error_log( "Content-Length: $size" );
            error_log( "Content-Disposition: attachment;filename=\"download\"" );
        }

        header( "Content-Type: application/octet-stream"                    );
        header( "Content-Length: $size"                                     );
        header( "Content-Disposition: attachment;filename=\"download.csv\"" );
        header( "Pragma: public"                                            );
        header( "Cache-Control: must-revalidate, post-check=0, pre-check=0" );
        header( "Connection: close" );
        echo $content;

        flush();
        exit;
    }
}
function GenerateMap( $base64_encoded_csv_map )
{
    $csv_map =  base64_decode( $base64_encoded_csv_map );

    if ( defined( "VERBOSE" ) && VERBOSE ) error_log( "GenerateMap( \"$csv_map\" )" );

    //
    //  csv_map=Family+Name|family_name,...
    //

    $map = null;

    if ( $csv_map )
    {
        $map  = array();
        $bits = explode( ",", $csv_map );
        
        foreach ( $bits as $mapping )
        {
            $pieces = explode( "|", $mapping );

            if ( 2 == count( $pieces ) )
            {
                $key = urldecode( $pieces[0] );
                $val = urldecode( $pieces[1] );
            
                $map[$key] = $val;
            }

            if ( defined( "VERBOSE" ) && VERBOSE ) error_log( "Mapping( $mapping ) : $key | $val" );
        }
    }
    return $map;
}

Log to Pub Sub

use Google\Cloud\PubSub\PubSubClient;

function LogToPubSub( $environment, $db_info, $db_version, $sp_call, $response, $delta )
{
    if ( "TRUE" == getenv( "USE_GOOGLE_PUB_SUB" ) )
    {
        $topic = GetPubSubTopic();

        $procedure = $sp_call->proc_name . "('" . join( "','", $sp_call->arguments ) . "')";

        if ( !$topic )
        {
            NeoLog( "Not logged to Google PubSub" );
        }
        else
        {
            $now          = date( "c", time() );
            $api_hostname = $environment['server_name' ];
            $endpoint     = $environment['redirect_url'];
            $request      = $environment['request'     ];
            $db           = $db_info->dbname . $db_version;
            $db_hostname  = DB_HOSTNAME;
            $sql64        = base64_encode( $procedure );
            $ip_address   = $_SERVER['SERVER_ADDR'];

            $requesting_url           = array_get(  $request, "wab_requesting_url" );

            $cgi_request_method       = array_get( $_SERVER , "REQUEST_METHOD"       );
            $cgi_request_time         = array_get( $_SERVER , "REQUEST_TIME"         );
            $cgi_http_accept          = array_get( $_SERVER , "HTTP_ACCEPT"          );
            $cgi_http_accept_charset  = array_get( $_SERVER , "HTTP_ACCEPT_CHARSET"  );
            $cgi_http_accept_encoding = array_get( $_SERVER , "HTTP_ACCEPT_ENCODING" );
            $cgi_http_accept_language = array_get( $_SERVER , "HTTP_ACCEPT_LANGUAGE" );
            $cgi_http_referer         = array_get( $_SERVER , "HTTP_REFERER"         );
            $cgi_http_user_agent      = array_get( $_SERVER , "HTTP_USER_AGENT"      );
            $cgi_remote_addr          = array_get( $_SERVER , "HTTP_REMOTE_ADDR"     );
            $cgi_remote_port          = array_get( $_SERVER , "HTTP_REMOTE_PORT"     );
            $cgi_remote_user          = array_get( $_SERVER , "HTTP_REMOTE_USER"     );

            $json  = "{\n";
            $json .= "\"ts\":                       \"$now\",\n";
            $json .= "\"api_hostname\":             \"$api_hostname\",\n";
            $json .= "\"endpoint\":                 \"$endpoint\",\n";
            $json .= "\"type\":                     \"r/w\",\n";
            $json .= "\"status\":                   \"$response->status\",\n";
            $json .= "\"error\":                    \"$response->error\",\n";
            $json .= "\"sql64\":                    \"$sql64\",\n";
            $json .= "\"response_time_ms\":         \"$response->microsecs\",\n";
            $json .= "\"db\":                       \"$db\",\n";
            $json .= "\"db_version\":               \"$db_version\",\n";
            $json .= "\"db_hostname\":              \"$db_hostname\",\n";
            $json .= "\"ip_address\":               \"$ip_address\",\n";
            $json .= "\"requesting_url\":           \"$requesting_url\",\n";
            $json .= "\"cgi_request_method\":       \"$cgi_request_method\",\n";
            $json .= "\"cgi_request_time\":         \"$cgi_request_time\",\n";
            $json .= "\"cgi_http_accept\":          \"$cgi_http_accept\",\n";
            $json .= "\"cgi_http_accept_charset\":  \"$cgi_http_accept_charset\",\n";
            $json .= "\"cgi_http_accept_encoding\": \"$cgi_http_accept_encoding\",\n";
            $json .= "\"cgi_http_accept_language\": \"$cgi_http_accept_language\",\n";
            $json .= "\"cgi_http_referer\":         \"$cgi_http_referer\",\n";
            $json .= "\"cgi_http_user_agent\":      \"$cgi_http_user_agent\",\n";
            $json .= "\"cgi_remote_addr\":          \"$cgi_remote_addr\",\n";
            $json .= "\"cgi_remote_port\":          \"$cgi_remote_port\",\n";
            $json .= "\"cgi_remote_user\":          \"$cgi_remote_user\"\n";
            $json .= "}\n";

            try
            {
                $topic->publish( ['data' => $json] );

                NeoLog( "Logged to Google PubSub" );
            }
            catch ( Exception $ex )
            {
                NeoLog( $ex );
            }
        }
    }
}
function GetPubSubTopic()
{
    $topic = null;

    NeoLogIn();

    if ( !getenv( "GOOGLE_APPLICATION_CREDENTIALS" ) )
    {
        NeoLog( "!!! WARNING !!! GOOGLE_APPLICATION_CREDENTIALS not defined in Apache configuration, but USE_GOOGLE_PUB_SUB is!" );
    }
    else
    if ( !getenv( "GOOGLE_PUB_SUB_PROJECT" ) )
    {
        NeoLog( "!!! WARNING !!! GOOGLE_PUB_SUB_PROJECT not defined in Apache configuration, but USE_GOOGLE_PUB_SUB is!" );
    }
    else
    if ( !getenv( "GOOGLE_PUB_SUB_TOPIC" ) )
    {
        NeoLog( "!!! WARNING !!! GOOGLE_PUB_SUB_TOPIC not defined in Apache configuration, but USE_GOOGLE_PUB_SUB is!" );
    }
    else
    {
        $pubsub_project = getenv( "GOOGLE_PUB_SUB_PROJECT" );
        $pubsub_topic   = getenv( "GOOGLE_PUB_SUB_TOPIC"   );

        try
        {
            $pubsub = new PubSubClient( ['projectId' => $pubsub_project] );
            $topic  = $pubsub->topic( $pubsub_topic );
        }
        catch( Exception $e )
        {
            NeoLog( "!!! WARNING !!! Could not use GOOGLE credentials: " . getEnv( "GOOGLE_APPLICATION_CREDENTIALS" ) );
        }
    }

    NeoLogOut();

    return $topic;
}

A -- Strings

<?php
function array_get( $array, $key )
{
    return array_key_exists( $key, $array ) ? $array[$key] : "";
}
function string_contains( $haystack, $needle )
{
    return (0 == strlen($needle)) || (false !== strpos( $haystack, $needle ));
}
function string_has_prefix( $haystack, $needle )
{
    return (0 == strlen($needle)) || (0 === strpos( $haystack, $needle ));
}
function string_has_suffix( $haystack, $needle )
{
    $expected = strlen( $haystack ) - strlen( $needle );

    return (0 == strlen($needle)) || ($expected === strrpos( $haystack, $needle ));
}
function test()
{
    if (    string_contains( "www.example.com", ""                ) ) echo "Passed #1" . "\n";
    if (    string_contains( "www.example.com", "www."            ) ) echo "Passed #2" . "\n";
    if (    string_contains( "www.example.com",    ".example."    ) ) echo "Passed #3" . "\n";
    if (    string_contains( "www.example.com",            ".com" ) ) echo "Passed #4" . "\n";
    if (   !string_contains( "www.example.com", "wwl"             ) ) echo "Passed #5" . "\n";
    if (   !string_contains( "www.example.com",    ".exanple"     ) ) echo "Passed #6" . "\n";
    if (   !string_contains( "www.example.com",            ".con" ) ) echo "Passed #7" . "\n";

    if (  string_has_prefix( "www.example.com", ""                ) ) echo "Passed #8" . "\n";
    if (  string_has_prefix( "www.example.com", "www."            ) ) echo "Passed #9" . "\n";
    if (  string_has_prefix( "www.example.com", "www.example."    ) ) echo "Passed #A" . "\n";
    if (  string_has_prefix( "www.example.com", "www.example.com" ) ) echo "Passed #B" . "\n";
    if ( !string_has_prefix( "www.example.com", "wwl"             ) ) echo "Passed #C" . "\n";
    if ( !string_has_prefix( "www.example.com", "www.exanple"     ) ) echo "Passed #D" . "\n";
    if ( !string_has_prefix( "www.example.com", "www.example.con" ) ) echo "Passed #E" . "\n";

    if (  string_has_suffix( "www.example.com", ""                ) ) echo "Passed #F" . "\n";
    if (  string_has_suffix( "www.example.com",            ".com" ) ) echo "Passed #G" . "\n";
    if (  string_has_suffix( "www.example.com",    ".example.com" ) ) echo "Passed #H" . "\n";
    if (  string_has_suffix( "www.example.com", "www.example.com" ) ) echo "Passed #I" . "\n";
    if ( !string_has_suffix( "www.example.com",            ".con" ) ) echo "Passed #J" . "\n";
    if ( !string_has_suffix( "www.example.com",    ".example.con" ) ) echo "Passed #K" . "\n";
    if ( !string_has_suffix( "www.example.com", "www.example.con" ) ) echo "Passed #L" . "\n";
}
<?php

class CSV
{
    static $newline = "\r\n";


    static function PlainText()
    {
        header( "Content-Type: text/plain" );
    }

    static function InitiateDownload( $filename="download.csv" )
    {
        header( "Content-Type: application/octet-stream" );
        header( "Content-Disposition: attachment; filename=\"$filename\"" );
        header("Pragma: public");
        header("Cache-Control: must-revalidate, post-check=0, pre-check=0");
    }

    static function encode( $results, $numbered = true, $double_spaced = false )
    {
        $ret = "";

        if ( is_array( $results ) && (0 < count( $results ) ) )
        {
            $ret .= CSV::Heading( $results, $numbered );
            $ret .= CSV::Rows( $results, $numbered, $double_spaced );
        }
        
        return $ret;
    }

    static function Echo( $results, $numbered = true, $double_spaced = false )
    {
        if ( is_a( $results, 'Results' ) )
        {
            $it = $results->iterator();

            if ( $it->hasNext() )
            {
                NeoLog( "CSV::Echo" );

                CSV::EchoHeading( $it, $numbered );
                CSV::EchoRows   ( $it, $numbered, $double_spaced );
            }
        }
    }

    static function EchoHeading( $it, $numbered )
    {
        $heading = "";
    
        if ( $it->hasFirst() )
        {
            $tuple = $it->getFirst();
            $sep   = "";
        
            foreach ( $tuple as $key => $value )
            {
                $heading .= $sep;
                $heading .= $key;
                $sep = ",";
            }

            unset( $tuple ); $tuple = null;
        }
        if ( $numbered ) $heading .= ",#";

        echo $heading .= self::$newline;
    }
    
    static function Heading( $it, $numbered )
    {
        $heading = "";
    
        foreach ( $results as $tuple )
        {
            $sep = "";
        
            foreach ( $tuple as $key => $value )
            {
                $heading .= $sep;
                $heading .= $key;
                $sep = ",";
            }
            break;
        }
        if ( $numbered ) $heading .= ",#";

        return $heading .= self::$newline;
    }

    static function EchoRows( $it, $numbered, $double_spaced = false )
    {
        $nr  = 0;
    
        while ( $it->hasNext() )
        {
            $tuple = $it->next();
            $row   = "";
            $sep   = "";

            $nr++;
        
            if ( $tuple )
            {
                foreach ( $tuple as $key => $value )
                {
                    $row .= $sep;

                    $value = html_entity_decode( $value, ENT_QUOTES );

                    if ( string_contains( $value, "," ) )
                    {
                        $row .= "\"$value\"";
                    }
                    else
                    {
                        $row .= "$value";
                    }
                    $sep = ",";
                }
                if ( $numbered ) $row .= ",$nr";

                if ( $double_spaced ) $row .= self::$newline;
            }

            $row .= self::$newline;

            echo $row;

            unset( $tuple ); $tuple = null;

            flush();
        }
    }

    static function Rows( $results, $numbered, $double_spaced = false )
    {
        $nr   = 0;
        $rows = "";
    
        foreach ( $results as $tuple )
        {
            $nr++;
            $sep = "";
        
            foreach ( $tuple as $key => $value )
            {
                $rows .= $sep;

                $value = html_entity_decode( $value, ENT_QUOTES );

                if ( string_contains( $value, "," ) )
                {
                    $rows .= "\"$value\"";
                }
                else
                {
                    $rows .= "$value";
                }
                $sep = ",";
            }
            if ( $numbered ) $rows .= ",$nr";

            $rows .= self::$newline;

            if ( $double_spaced ) $rows .= self::$newline;
        }

        return $rows;
    }
    
    static function MapValues( $map, $tuples )
    {
        $mapped_tuples = array();
    
        foreach ( $tuples as $obj )
        {
            $tuple  = (array) $obj;
            $mapped = array();
            
            foreach ( $map as $label => $key )
            {
                if ( array_key_exists( $key, $tuple ) )
                {
                    $mapped[$label] = $tuple[$key];
                }
                else
                {
                    error_log( "Could not find $key in tuple." );
                }
            }
            
            $mapped_tuples[] = $mapped;
        }
        
        return $mapped_tuples;
    }
}

Experimental

The experimental function is used to determine if experimental code paths should be followed for the current domain.

<?php
//  Copyright (c) 2009, 2020 Daniel Robert Bradley. All rights reserved.
//  This software is distributed under the terms of the GNU Lesser General Public License version 2.1
?>
<?php

function Experimental()
{
    return
        ("TRUE" == getenv( "USE_EXPERIMENTAL"))
        ||
        (defined( "EXPERIMENTAL" ) && array_key_exists( $_SERVER["SERVER_NAME"], EXPERIMENTAL ) && EXPERIMENTAL[$_SERVER["SERVER_NAME"]]);
}

Input

<?php
//  Copyright (c) 2009, 2010 Daniel Robert Bradley. All rights reserved.
//  This software is distributed under the terms of the GNU Lesser General Public License version 2.1
?>
<?php

class Output
{
    function println()
    {}

    function indent()
    {}

    function outdent()
    {}
}

function DBi_escape( $string )
{
    return $string;

    $db = DBi_anon();
    
    if ( $db->connect( new NullPrinter() ) )
    {
        return $db->escape( $string );
    }
}

class Input
{
    static function FilterInput( $request, $debug )
    {
        $debug    = new Output();
        $filtered = array();

        $debug->println( "<!-- FilterInput() start -->" );
        $debug->indent();
        {
            
            $debug->println( "<!-- REQUEST -->" );
            $debug->indent();
            {
                foreach ( $request as $key => $val )
                {
                    $filtered_key = Input::Filter( $key );
                    $filtered_val = Input::Filter( $val );

                    $filtered[$filtered_key] = $filtered_val;

                    if ( is_array( $filtered_val ) )
                    {
                        $debug->println( "<!-- \"$filtered_key\" | Array -->" );
                    }
                    else
                    {
                        $debug->println( "<!-- \"$filtered_key\" | \"$filtered_val\" -->" );
                    }
                }
            }
            $debug->outdent();

            $debug->println( "<!-- COOKIE -->" );
            $debug->indent();
            {
                foreach ( $_COOKIE as $key => $val )
                {
                    if ( ! array_key_exists( $key, $filtered ) )
                    {
                        $filtered_key = Input::Filter( $key );
                        $filtered_val = Input::Filter( $val );

                        $filtered[$filtered_key] = $filtered_val;
                        $debug->println( "<!-- \"$filtered_key\" | \"$filtered_val\" -->" );
                    }
                }
            }
            $debug->outdent();
        }
        $debug->outdent();
        $debug->println( "<!-- FilterInput() end -->" );

        return $filtered;
    }

    static function Filter( $value )
    {
        if ( is_array( $value ) )
        {
            $ret = array();
            
            foreach ( $value as $key => $val )
            {
                $filtered_key = Input::Filter( $key );
                $filtered_val = Input::Filter( $val );
            
                $ret[$filtered_key] = $filtered_val;
            }
            
            return $ret;
        }
        else
        if ( is_string( $value ) )
        {
            $value = Input::unidecode( $value );
            $value = htmlspecialchars( $value, ENT_QUOTES, 'UTF-8', false );
            $value = addslashes( $value );
            $value = DBi_escape( $value );

            $value = str_replace( "\n\r",  "<br>", $value );
            $value = str_replace(   "\n",  "<br>", $value );
            $value = str_replace( "\\\\", "\", $value );
            $value = str_replace( "\x09",     " ", $value );

            return $value;
        }
        else
        if ( is_null( $value ) )
        {
            return "";
        }
        else
        {
            error_log( "Input::Filter( $value ): unexpected value!" );
        }
    }

    static function unidecode( $value )
    {
        $str = "";
        $n   = strlen( $value );
        $i   = 0;
        
        while ( $i < $n )
        {
            $ch  = substr( $value, $i, 1 );
            $val = ord( $ch );

            if ( ($val == (0xFC | $val)) && ($i+5 < $n) )       // 6 byte unicode
            {
                $str .= Input::utf2html( substr( $value, $i, 6 ) );
                $i   += 6;
            }
            else
            if ( ($val == (0xF8 | $val)) && ($i+4 < $n) )       // 5 byte unicode
            {
                $str .= Input::utf2html( substr( $value, $i, 5 ) );
                $i   += 5;
            }
            else
            if ( ($val == (0xF0 | $val)) && ($i+3 < $n) )       // 4 byte unicode
            {
                $str .= Input::utf2html( substr( $value, $i, 4 ) );
                $i   += 4;
            }
            else
            if ( ($val == (0xE0 | $val)) && ($i+2 < $n) )       // 3 byte unicode
            {
                $str .= Input::utf2html( substr( $value, $i, 3 ) );
                $i   += 3;
            }
            else
            if ( ($val == (0xC0 | $val)) && ($i+1 < $n) )   // 2 byte unicode
            {
                $str .= Input::utf2html( substr( $value, $i, 2 ) );
                $i   += 2;
            }
            else
            if ( $val == (0x80 | $val) )        // extra byte
            {
                error_log( "Warning detected invalid unicode" );
                $str .= '?';
                $i++;
            }
            else                                // ascii character
            {
                $str .= $ch;
                $i++;
            }
        }
        return $str;
    }

    static function utf2html( $string )
    {
        $array  = Input::utf8_to_unicode( $string );
        $string = Input::unicode_to_entities( $array );
        
        return $string;
    }

    static function utf8_to_unicode( $str )
    {
        $unicode = array();
        $values = array();
        $lookingFor = 1;
        
        for ($i = 0; $i < strlen( $str ); $i++ ) {

            $thisValue = ord( $str[ $i ] );
            
            if ( $thisValue < 128 ) $unicode[] = $thisValue;
            else {
            
                if ( count( $values ) == 0 ) $lookingFor = ( $thisValue < 224 ) ? 2 : 3;
                
                $values[] = $thisValue;
                
                if ( count( $values ) == $lookingFor ) {
            
                    $number = ( $lookingFor == 3 ) ?
                        ( ( $values[0] % 16 ) * 4096 ) + ( ( $values[1] % 64 ) * 64 ) + ( $values[2] % 64 ):
                        ( ( $values[0] % 32 ) * 64 ) + ( $values[1] % 64 );
                        
                    $unicode[] = $number;
                    $values = array();
                    $lookingFor = 1;
            
                } // if
            
            } // if
            
        } // for

        return $unicode;
    
    }

    static function unicode_to_entities( $unicode )
    {
        $entities = '';
        foreach( $unicode as $value ) $entities .= '&#' . $value . ';';
        return $entities;
    }
}
<?php
//  Copyright (c) 2014 Daniel Robert Bradley. All rights reserved.
//  This software is distributed under the terms of the GNU Lesser General Public License version 2.1
?>
<?php

class JSON4
{
    static function EncodeResults( $indent, $results )
    {
        $json = "";
    
        $json .= "{";
        
        if ( is_array( $results ) && (0 < count( $results ) ) )
        {
            $json .= '"results":';
            $json .= "[";
            
            $sep = "";
            
            foreach ( $results as $tuple )
            {
                $json .= $sep;
                $json .= self::EncodeTuple( $indent + 1, $tuple );
                $sep = ",";
            }
            
            $json .= "]";
        }
        
        $json .= "}";

        return $json;
    }

    static function EncodeTuple( $indent, $tuple )
    {
        $json = "\n" . str_repeat( " ", $indent * 4 );
        $json .= "{";
        $sep = "";
    
        foreach ( $tuple as $key => $value )
        {
            $json .= $sep;

            $json .= self::EncodeStringValue( $indent + 1, $key, $value );

            $sep = ",";
        }

        $json .= "\n" . str_repeat( " ", $indent * 4 ) . "}";

        return $json;
    }

    static function Encode( $indent, $something )
    {
        $json = "" . str_repeat( " ", $indent * 4 );

        if ( is_array( $something ) )
        {
            $json .= self::EncodeArray( $indent + 1, $something );
        }
        else
        {
            $json .= self::EncodeObject( $indent + 1, $something );
        }

        return $json;
    }

    static function EncodeArray( $indent, $array )
    {
        $json = "\n" . str_repeat( " ", $indent * 4 );
        $json .= "[";
        $sep = "";
        
        if ( self::is_assoc( $array ) )
        {
            foreach ( $array as $string => $value )
            {
                $json .= $sep . "{";
                
                $json .= self::EncodeStringValue( $indent + 1, $string, $value );
                
                $json .= "}";

                $sep = ",";
            }
        }
        else
        {
            foreach ( $array as $value )
            {
                $json .= $sep;
                
                $json .= self::EncodeValue( $indent + 1, $value );
                
                $sep = ",";
            }
        }
        $json .= "\n" . str_repeat( " ", $indent * 4 ) . "]";

        return $json;
    }

    static function EncodeObject( $indent, $object )
    {
        $json = "\n" . str_repeat( " ", $indent * 4 );
        $json .= "{";
        $sep = "";

        foreach ( $object as $member => $value )
        {
            $json .= $sep;
            
            $json .= self::EncodeStringValue( $indent + 1, $member, $value );

            $sep = ",";
        }
        $json .= "\n" . str_repeat( " ", $indent * 4 ) . "}";

        return $json;
    }

    static function EncodeStringValue( $indent, $string, $value )
    {
        $json = "\n" . str_repeat( " ", $indent * 4 );
        $json .= self::EncodeString( $indent + 1, $string );
        $json .= ":";
        $json .= self::EncodeValue( $indent + 1, $value );

        return $json;
    }

    static function EncodeString( $indent, $string )
    {
        $json = "";
        $escaped = str_replace( "\\", "\\\\", $string );

        $json .= "\"$escaped\"";

        return $json;
    }

    static function EncodeValue( $indent, $value )
    {
        $json = "";
        if ( is_array( $value ) )
        {
            $json .= self::EncodeArray( $indent, $value );
        }
        else
        if ( is_string( $value ) )
        {
            $json .= self::EncodeString( $indent, $value );
        }
        else
        if ( is_numeric( $value ) )
        {
            $json .= self::EncodeNumber( $indent, $value );
        }
        else
        if ( is_null( $value ) )
        {
            $json .= "null";
        }
        else
        if ( true === $value )
        {
            $json .= "true";
        }
        else
        if ( false === $value )
        {
            $json .= "false";
        }
        else
        {
            $json .= self::EncodeObject( $indent, $value );
        }

        return $json;
    }

    static function EncodeNumber( $indent, $number )
    {
        $json = "";
        $json .= $number;

        return $json;
    }

    static function is_assoc( $a )
    {
        $assoc = true;
    
        $keys = array_keys( $a );
        foreach ( $keys as $key )
        {
            if ( is_numeric( $key ) && (0 == $key) ) $assoc = false;
            break;
        }
        return $assoc;
    }

    static function is_assocx( $a )
    {
        $b = array_keys($a);

        return ($a != array_keys($b));
    }
}
<?php
//  Copyright (c) 2015 Daniel Robert Bradley. All rights reserved.
//  This software is distributed under the terms of the GNU Lesser General Public License version 2.1
?>
<?php

class JStream
{
    static function Encode( $indent, $something )
    {
        if ( is_array( $something ) )
        {
            self::EncodeArray( $indent, $something );
        }
        else
        {
            self::EncodeObject( $indent, $something );
        }
    }

    static function EncodeResults( $indent, $results )
    {
        echo "{";
        
        if ( is_array( $results ) && (0 < count( $results ) ) )
        {
            echo '"results":';
            echo "[";
            
            $sep = "";
            
            foreach ( $results as $tuple )
            {
                echo $sep;
                self::EncodeTuple( $index + 1, $tuple );
                $sep = ",";
            }
            
            echo "]";
        }
        
        echo "}";
    }

    static function EncodeTuple( $indent, $tuple )
    {
        echo "\n" . str_repeat( " ", $indent * 4 ) . "{";
        $sep = "";
    
        foreach ( $tuple as $key => $value )
        {
            echo $sep;

            self::EncodeStringValue( $indent + 1, $key, $value );

            $sep = ",";
        }

        echo "\n" . str_repeat( " ", $indent * 4 ) . "}";
    }

    static function EncodeArray( $indent, $array )
    {
        echo "\n" . str_repeat( " ", $indent * 4 );
        echo "[";
        $sep = "";
        
        if ( self::is_assoc( $array ) )
        {
            foreach ( $array as $string => $value )
            {
                echo $sep . "{";
                
                self::EncodeStringValue( $indent + 1, $string, $value );
                
                echo "}";

                $sep = ",";
            }
        }
        else
        {
            foreach ( $array as $value )
            {
                echo $sep;
                
                self::EncodeValue( $indent + 1, $value );
                
                $sep = ",";
            }
        }

        echo "\n" . str_repeat( " ", $indent * 4 );
        echo "]";
    }

    static function EncodeObject( $indent, $object )
    {
        echo "\n" . str_repeat( " ", $indent * 4 );
        echo "{";
        $sep = "";

        foreach ( $object as $member => $value )
        {
            echo $sep;
            
            self::EncodeStringValue( $indent + 1, $member, $value );

            $sep = ",";
        }

        if ( property_exists( $object, "results" ) )
        {
            $results = $object->results;

            //
            //  This must happen _after_ the results have been sent.
            //
            $count = is_array( $results ) ? count( $results ) : (is_a( $results, 'Results' ) ? $results->numRows() : '?');

            echo $sep . "\n";
            echo str_repeat( " ", ($indent + 1) * 4 );
            echo json_encode( "count" );
            echo ":";
            echo json_encode( $count );
        }

        echo "\n" . str_repeat( " ", $indent * 4 );
        echo "}";
    }

    static function EncodeStringValue( $indent, $string, $value )
    {
        echo "\n" . str_repeat( " ", $indent * 4 );

        if ( is_string( $value ) )
        {
            $headers = apache_request_headers();

            if ( array_key_exists( 'Accept', $headers ) )
            {
                switch( $headers['Accept'] )
                {
                case "application/json":
                    $value = html_entity_decode( $value, ENT_QUOTES );
                    $value = str_replace( "<br>", "\n", $value );
                    break;
                }
            }
        }

        if ( string_has_prefix( $string, "__json_" ) )
        {
            self::EncodeString( $indent, substr( $string, 7 ) );
            echo ":";
            echo json_encode( json_decode( $value ) );
        }
        else
        {
            self::EncodeString( $indent, $string );
            echo ":";
            self::EncodeValue( $indent, $value );
        }
    }

    static function EncodeString( $indent, $string )
    {
        $escaped = json_encode( $string ); // str_replace( "\\", "\\\\", $string );

        if ( false === $escaped )
        {
            error_log( "ERROR1: " . var_export( $string,  true ) );
            $escaped = json_encode( htmlentities( $string ) );
        }

        if ( false === $escaped )
        {
            error_log( "ERROR2: " . var_export( $string,  true ) );
            $escaped = json_encode( "" );
        }

        echo $escaped;
    }

    static function EncodeValue( $indent, $value )
    {
        if ( true === $value )
        {
            echo "true";
        }
        else
        if ( false === $value )
        {
            echo "false";
        }
        else
        if ( is_array( $value ) )
        {
            self::EncodeArray( $indent, $value );
        }
        else
        if ( is_string( $value ) )
        {
            self::EncodeString( $indent, $value );
        }
        else
        if ( is_numeric( $value ) )
        {
            self::EncodeNumber( $indent, $value );
        }
        else
        if ( is_null( $value ) )
        {
            echo "null";
        }
        else
        if ( is_a( $value, 'Results' ) )
        {
            if ( $value->isProcessed() )
            {
                //  This should never be executed because the 'Results' object
                //  is only every "processed" by the TranslateToCSV code path
                //  which is orthogonal to this one...
                //  I will remove this when I remove the "map" code path
                //  from TranslateToCSV.

                NeoLog( "Abort!!! Invalid code path." );
                //$array = $value->toArray();
                //self::EncodeArray( $indent, $array );
            }
            else
            {
                self::EncodeResultArray( $indent, $value );
            }
        }
        else
        {
            self::EncodeObject( $indent, $value );
        }
    }

    static function EncodeResultArray( $indent, $result_array )
    {
        echo "\n" . str_repeat( " ", $indent * 4 );
        echo "[";
        $sep = "";
        
        $it = $result_array->iterator();

        while ( $it->hasNext() )
        {
            $value = $it->next();

            echo $sep;
            \JStream::EncodeValue( $indent + 1, $value ); 
            $sep = ",";

            unset( $value ); $value = null;
        }

        echo "\n" . str_repeat( " ", $indent * 4 );
        echo "]";
    }

    static function EncodeNumber( $indent, $number )
    {
        echo json_encode( $number );
    }

    static function is_assoc( $a )
    {
        $assoc = true;
    
        $keys = array_keys( $a );
        foreach ( $keys as $key )
        {
            if ( is_numeric( $key ) && (0 == $key) ) $assoc = false;
            break;
        }
        return $assoc;
    }

    static function is_assocx( $a )
    {
        $b = array_keys($a);

        return ($a != array_keys($b));
    }
}

Log

<?php

class Neo
{
    static $neolog_indent;
    static $neolog_connection_id;
    static $neolog_log_level;

    static function Init()
    {
        $strong;
        self::$neolog_connection_id = bin2hex( openssl_random_pseudo_bytes( 2, $strong ) );

        self::$neolog_log_level = intval( getenv( "SHOW_LOG_TO_LEVEL" ) );

        if ( ! is_numeric( self::$neolog_log_level ) )
        {
            self::$neolog_log_level = 100;
        }
    }

    static function In()
    {
        self::$neolog_indent++;
    }

    static function Out()
    {
        if ( 0 < self::$neolog_indent ) self::$neolog_indent--;
    }

    static function Log( $text, $priority = LOG_INFO )
    {
        $indent = intval( self::$neolog_indent    );
        $level  = intval( self::$neolog_log_level );

        if ( $indent <= $level )
        {
            self::LogError( $text, $priority );
        }
    }

    static function LogError( $text, $priority = LOG_ERR )
    {
        $now = isset( $_SERVER ) ? "" : date( DATE_ISO8601 );
    
        $stars = str_repeat( "+", self::$neolog_indent );

        error_log( sprintf( "[%s] %s%-8s %s", self::$neolog_connection_id, $now, $stars, $text ) );

        syslog( $priority, $stars . " " . $text );
    }
}

Neo::Init();

function NeoLog( $text, $priority = LOG_INFO )
{
    Neo::Log( $text, $priority );
}

function NeoLogError( $text )
{
    Neo::LogError( $text, LOG_ERR );
}

function NeoLogIn()
{
    Neo::In();
}

function NeoLogOut()
{
    Neo::Out();
}

function NeoLog_DecodeFacility( $facility_name )
{
    switch( $facility_name )
    {
    case "AUTH":
        return LOG_AUTH;

    case "AUTHPRIV":
        return LOG_AUTHPRIV;

    case "DAEMON":
        return LOG_DAEMON;

    case "KERN":
        return LOG_KERN;

    case "LOCAL0":
        return LOG_LOCAL0;

    case "LOCAL1":
        return LOG_LOCAL1;

    case "LOCAL2":
        return LOG_LOCAL2;

    case "LOCAL3":
        return LOG_LOCAL3;

    case "LOCAL4":
        return LOG_LOCAL4;

    case "LOCAL5":
        return LOG_LOCAL5;

    case "LOCAL6":
        return LOG_LOCAL6;

    case "LOCAL7":
        return LOG_LOCAL7;

    case "LPR":
        return LOG_LPR;

    case "MAIL":
        return LOG_MAIL;

    case "NEWS":
        return LOG_NEWS;

    case "SYSLOG":
        return LOG_SYSLOG;

    case "USER":
        return LOG_USER;

    case "UUCP":
        return LOG_UUCP;

    default:
        return LOG_USER;
    }
}

MySQL

<?php

function MySQLCreate()
{
    //NeoLog( 3, "DB_HOSTNAME: " . DB_HOSTNAME );
    //NeoLog( 3, "DB_USERNAME: " . DB_USERNAME );
    //NeoLog( 3, "DB_PASSWORD: " . DB_PASSWORD );
    //NeoLog( 3, "SSL_BASE:    " . SSL_BASE    );

    $m = mysqli_init();

    if ( !$m )
    {
        NeoLogError( "Could not initialise 'mysqli'" );
        exit;
    }

    if ( MYSQLI_OPT_READ_TIMEOUT )
    {
        if ( defined( "USE_MYSQLI_OPT_READ_TIMEOUT" ) )
        {
            mysqli_options( $m, MYSQLI_OPT_READ_TIMEOUT, USE_MYSQLI_OPT_READ_TIMEOUT );
        }
    }
    else
    {
        NeoLog( "Read timeout not supported" );
    }

    ConfigureSSL( $m, SSL_BASE, DB_HOSTNAME );

    $connection = @$m->real_connect( DB_HOSTNAME, DB_USERNAME, DB_PASSWORD, null, null, null, MYSQLI_CLIENT_SSL_DONT_VERIFY_SERVER_CERT );

    if ( !$connection )
    {
        if ( "No such file or directory" == $m->error )
        {
            NeoLogError( "Aborting: could not connect to " . DB_HOSTNAME . " using " . DB_USERNAME . ":" . DB_PASSWORD );
            exit;
        }
        else
        if ( "php_network_getaddresses: getaddrinfo failed: Name or service not known" == $m->error )
        {
            NeoLogError( "Aborting: could not resolve: " . DB_HOSTNAME );
            exit;
        }
        else
        {
            NeoLogError( "Aborting: unexpected error: " . $m->error );
            exit;
        }
    }

    return $m; // Same as $m.
}

function MySQLClose( $m )
{
    mysqli_close( $m );
}

function MySQLInfo( $mysql, $sql_query )
{
    $tuples   = array();
    $resource = mysqli_query( $mysql, $sql_query );

    if ( !$resource )
    {
        NeoLog( $mysql->error );
        exit;
    }
    else
    {
        while ( $row = mysqli_fetch_array( $resource, MYSQLI_ASSOC ) )
        {
            $tuples[] = $row;
        }
    }
    return $tuples;
}

function MySQLFunction( $db, $function )
{
    $ret      = null;
    $sql      = "SELECT $function";
    $m        = MySQLCreate();

    if ( ! mysqli_select_db( $m, $db ) )
    {
        NeoLog( "Aborting, could not select database! " . $db );
        exit;
    }
    else
    {
        $resource = mysqli_query( $m, $sql );

        if ( True === $resource )
        {
            $ret = True;
        }
        else
        if( False === $resource )
        {
            $ret = False;
        }
        else
        if ( $resource )
        {
            $row = mysqli_fetch_array( $resource, MYSQLI_NUM );
            $ret = array_key_exists( 0, $row ) ? $row[0] : NULL;
            mysqli_free_result( $resource );
        }
        else
        {
            NeoLog( "Aborting, " . $m->error );
            exit;
        }
        mysqli_next_result( $m );
    }

    MySQLClose( $m );

    return $ret;
}

function MySQLProcedure( $db, $sp_call, &$procedure = "" )
{
    $ret = False;

    if ( !is_object( $sp_call ) )
    {
        debug_backtrace();
    }

    $proc_name = $sp_call->proc_name;
    $arguments = $sp_call->arguments;

    $m = MySQLCreate();

    if ( ! mysqli_select_db( $m, $db ) )
    {
        NeoLog( "Aborting, could not select database! " . $db );
        MySQLClose( $m );
        exit;
    }
    else
    {
        $count    = 0;
        $filtered = array();
        foreach( $arguments as $argument )
        {
            if ( is_null( $argument ) )
            {
                $filtered[] = "NULL";
            }
            else
            {
                $filtered[] = "'" . mysqli_real_escape_string( $m, $argument ) . "'";
            }
        }

        $procedure = $proc_name . "(" . join( ",", $filtered ) . ")";

        $sql_query = "CALL " . $procedure;

        if ( "wab_test" == $proc_name )
        {
            NeoLog( "Test: " . $sql_query );
        }

        try
        {
            $resource  = mysqli_query( $m, $sql_query );
        }
        catch ( Exception $e )
        {
            //  MySQL thrown errors seem to be now throwing exceptions...
            //  Is this a MariaDB thing; or updated phpmysqli...
            //
            $resource = False;
            $ret = $e->getMessage();
        }

        if ( True === $resource )
        {
            $ret = array();
        }
        else
        if( False === $resource )
        {
            $ret = $m->error;
        }
        else
        if ( $resource )
        {
            $ret = array();

            while ( $row = mysqli_fetch_array( $resource, MYSQLI_ASSOC ) )
            {
                $ret[] = (object) $row;
                $count++;
            }

            mysqli_free_result( $resource );
        }
        else
        {
            NeoLogError( "Called: " . $sql_query . ": threw ERROR: " . $m->error );
            MySQLClose( $m );
            exit;
        }
        mysqli_next_result( $m );

        if ( "TRUE" == getenv( "LOG_STORED_PROCEDURE_CALL" ) )
        {
            NeoLogError( "Called: " . $sql_query . ": returned " . $count . " rows" );
        }
    }

    MySQLClose( $m );

    return $ret;
}

class ResultIterator
{
    protected $m;
    protected $result;
    protected $row;
    protected $count = 0;

    function __construct( $anM, $aResult )
    {
        $this->m      = $anM;
        $this->result = $aResult;
        $this->row    = mysqli_fetch_array( $this->result, MYSQLI_ASSOC );
        $this->first  = $this->row;
    }

    function __destruct()
    {
        mysqli_free_result( $this->result );
        mysqli_next_result( $this->m      );
        MySQLClose        ( $this->m      );
    }

    function hasFirst()
    {
        return $this->first ? true : false;
    }

    function getFirst()
    {
        return (object) $this->first;
    }

    function hasNext()
    {
        return $this->row ? true : false;
    }

    function next()
    {
        $ret       = $this->row;
        $this->row = null;
        $this->row = mysqli_fetch_array( $this->result, MYSQLI_ASSOC );

        $this->count++;

        return (object) $ret;
    }

    function getCount()
    {
        return $this->count;
    }
}

class Results
{
    public    $sqlQuery;
    protected $it;
    protected $array;
    protected $result;

    function __construct( $sql_query, $anM, $aResult )
    {
        $this->sqlQuery = $sql_query;
        $this->it       = new ResultIterator( $anM, $aResult );
        $this->array    = null;
        $this->result   = $aResult;
    }

    function hasFirst()
    {
        return $this->it->hasFirst();
    }

    function getFirst()
    {
        return $this->it->getFirst();
    }


    //  Called by OutputResponse.TranslateToCSV
    function toArray()
    {
        if ( is_null( $this->array ) )
        {
            $this->array = array();

            while ( $this->it->hasNext() )
            {
                $this->array[] = (object) $this->it->next();
            }
        }

        return $this->array;
    }

    function toStringX()
    {
        return var_export( $this->array, true );
    }

    function isProcessed()
    {
        return !is_null( $this->array );
    }

    function encodeArrayX( $indent )
    {
        echo "\n" . str_repeat( " ", $indent * 4 );
        echo "[";
        $sep = "";

        while ( $this->it->hasNext() )
        {
            $value = (object) $this->it->next();

            echo $sep;
            \JStream::EncodeValue( $indent + 1, $value ); 
            $sep = ",";
        }

        echo "\n" . str_repeat( " ", $indent * 4 );
        echo "]";
    }

    function iterator()
    {
        return $this->it;
    }

    function numRows()
    {
        return $this->it->getCount();
        //return mysqli_num_rows( $this->result );
    }
}

class EmptyResults
{
    public    $sqlQuery;
    protected $array;

    function __construct( $sql_query )
    {
         $this->sqlQuery = $sql_query;
         $this->array    = array();
    }

    function numRows()
    {
        return 0;
    }
}

class ErrorResults
{
    public    $sqlQuery;
    public    $errorMessage;

    function __construct( $sql_query, $error_message )
    {
         $this->sqlQuery     = $sql_query;
         $this->errorMessage = $error_message;
    }

    function numRows()
    {
        return 'X';
    }
}

function MySQLProcedureResults( $db, $sp_call, &$procedure = "" )
{
    $ret = False;

    if ( !is_object( $sp_call ) )
    {
        debug_backtrace();
    }

    $proc_name = $sp_call->proc_name;
    $arguments = $sp_call->arguments;

    $m = MySQLCreate();

    if ( ! mysqli_select_db( $m, $db ) )
    {
        NeoLog( "Aborting, could not select database! " . $db );
        MySQLClose( $m );
        exit;
    }
    else
    {
        $filtered = array();
        foreach( $arguments as $argument )
        {
            if ( is_null( $argument ) )
            {
                $filtered[] = "NULL";
            }
            else
            {
                $filtered[] = "'" . mysqli_real_escape_string( $m, $argument ) . "'";
            }
        }

        $procedure = $proc_name . "(" . join( ",", $filtered ) . ")";

        $sql_query = "CALL " . $procedure;

        if ( "wab_test" == $proc_name )
        {
            NeoLog( "Test: " . $sql_query );
        }

        try
        {
            $resource  = mysqli_query( $m, $sql_query, MYSQLI_USE_RESULT );
        }
        catch ( Exception $e )
        {
            //  MySQL thrown errors seem to be now throwing exceptions...
            //  Is this a MariaDB thing; or updated phpmysqli...
            //
            $resource      = False;
            $error_message = $e->getMessage();
            $ret           = new ErrorResults( $sql_query, $error_message );
        }

        if ( "auth_login" == strtolower( $proc_name ) )
        {
            $sql_query = "CALL auth_login( [hidden] )";
        }

        if ( True === $resource )
        {
            $ret = new EmptyResults( $sql_query );
        }
        else
        if( False === $resource )
        {
            $ret = new ErrorResults( $sql_query, $m->error );
        }
        else
        if ( $resource )
        {
            $ret = new Results( $sql_query, $m, $resource );
        }
        else
        {
            NeoLog( "Aborting, " . $m->error );
            MySQLClose( $m );
            exit;
        }
    }

    if ( !is_a( $ret, 'Results' ) )
    {
        mysqli_next_result( $m );
        MySQLClose( $m );
    }

    return $ret;
}

SSL

<?php

function ConfigureSSL( $mysqli, $ssl_base, $host )
{
    if ( file_exists( "$ssl_base/$host/client-key.pem" ) )
    {
        mysqli_ssl_set
        (
            $mysqli,
            "$ssl_base/$host/client-key.pem",
            "$ssl_base/$host/client-cert.pem",
            "$ssl_base/$host/ca-cert.pem",
            NULL,
            NULL
        );
    }
    else
    if ( file_exists( "$ssl_base/$host/server-ca.pem" ) )
    {
        NeoLog( "Warning: not using x509 authenication as cannot find client key/cert." );

        mysqli_ssl_set
        (
            $mysqli,
            NULL,
            NULL,
            "$ssl_base/$host/server-ca.pem",
            NULL,
            NULL
        );
    }
    else
    if( "localhost" != $host )
    {
        NeoLog( "Could not locate SSL files: " . "$ssl_base/$host/client-key.pem" );
    }
}

Test

DROP   PROCEDURE Wab_Test;
DELIMITER //
CREATE PROCEDURE Wab_Test
(
    $Sid     TEXT,
    $apikey  CHAR(64),
    $date    DATE,
    $text    TEXT,
    $number  INT
)
SQL SECURITY DEFINER
COMMENT 'EXPORT'
BEGIN

    SELECT
        $Sid    AS Sid,
        $apikey AS apikey,
        $date   AS date,
        $text   AS text,
        $number AS number;

END
//
DELIMITER ;
DROP   PROCEDURE Raw_Wab_Test;
DELIMITER //
CREATE PROCEDURE Raw_Wab_Test
(
    $Sid     TEXT,
    $apikey  CHAR(64),
    $date    DATE,
    $text    TEXT,
    $number  INT
)
SQL SECURITY DEFINER
COMMENT 'EXPORT'
BEGIN

    SELECT
        $Sid    AS Sid,
        $apikey AS apikey,
        $date   AS date,
        $text   AS text,
        $number AS number;

END
//
DELIMITER ;