~thirdplace/components

d11ec1fe6850656c2299521af5097ebc97318253 — Dag a month ago 5cb014a main
trim
16 files changed, 56 insertions(+), 471 deletions(-)

M composer.json
M src/Application.php
D src/Console.php
M src/Renderer.php
M src/Session.php
D src/Shell.php
M src/common.php
M src/http/CachingHttpClient.php
D src/http/Cookie.php
M src/http/CurlHttpClient.php
D src/http/Header.php
M src/http/Request.php
M src/http/Response.php
D src/logger/MailgunHandler.php
M src/logger/StreamHandler.php
A tests/UrlTest.php
M composer.json => composer.json +0 -1
@@ 31,7 31,6 @@
            "./src/Clock.php",
            "./src/Renderer.php",
            "./src/Session.php",
            "./src/http/Response.php",
            "./src/Url.php"
        ]
    },

M src/Application.php => src/Application.php +3 -3
@@ 19,9 19,9 @@ final class Application
        // perhaps add a default logger
        ErrorHandler::register($container['error_logger']);

        $this->addRoute('GET', '/',    fn() => response("Hello World!\n", 200));
        $this->addRoute('GET', '/404', fn() => response("404 Page Not Found\n", 404));
        $this->addRoute('GET', '/405', fn() => response("405 Method Not Allowed\n", 405));
        $this->addRoute('GET', '/',    fn() => new Response("Hello World!\n", 200));
        $this->addRoute('GET', '/404', fn() => new Response("404 Page Not Found\n", 404));
        $this->addRoute('GET', '/405', fn() => new Response("405 Method Not Allowed\n", 405));
    }

    /**

D src/Console.php => src/Console.php +0 -126
@@ 1,126 0,0 @@
<?php
declare(strict_types=1);

namespace Thirdplace;

final class Console
{
    private const NC = "\033[0m";
    private const GREEN = "\033[0;32m";
    private const YELLOW = "\033[1;33m";
    private const RED = "\033[0;31m";

    public function print(string $s, ...$args)
    {
        printf($s, ...$args);
    }

    public function println(string $s = '', ...$args)
    {
        $this->print($s . "\n", ...$args);
    }

    public function greenln(string $s, ...$args)
    {
        $this->green($s . "\n", ...$args);
    }

    public function green(string $s, ...$args)
    {
        $this->print(self::GREEN . $s . self::NC, ...$args);
    }

    public function yellow(string $s, ...$args)
    {
        $this->print(self::YELLOW . $s . self::NC, ...$args);
    }

    public function yellowln(string $s, ...$args)
    {
        $this->yellow($s . "\n", ...$args);
    }

    public function red(string $s, ...$args)
    {
        $this->print(self::RED . $s . self::NC, ...$args);
    }

    public function redln(string $s, ...$args)
    {
        $this->red($s . "\n", ...$args);
    }

    /**
     * @param int|string $status
     */
    public function exit($status = 0)
    {
        exit($status);
    }

    public function table(array $headers, array $rows, int $maxWidth = 50)
    {
        // Find the longest column value
        $columnWidth = 3;
        foreach (array_merge($rows, [$headers]) as $values) {
            foreach ($values as $value) {
                $columnWidth = max($columnWidth, mb_strlen((string) $value));
            }
        }

        $columnWidth = min($columnWidth, $maxWidth);

        // Truncate header values
        foreach ($headers as $i => $header) {
            $headers[$i] = $this->truncate((string) $header, $columnWidth);
        }

        // Truncate row values
        foreach ($rows as $i => $row) {
            foreach ($row as $j => $value) {
                $rows[$i][$j] = $this->truncate((string) ($value ?? 'NULL'), $columnWidth);
            }
        }

        // Create bar and row formatter
        $bar = '';
        $format = '';
        foreach ($headers as $value) {
            $bar .= '+' . str_repeat('-', $columnWidth +2);
            $format .= '| %-' . ($columnWidth +1 + strlen($value) - mb_strlen($value)) . 's';
        }
        $bar .= '+';
        $format .= '|';

        // Render bar
        $this->println($bar);

        // Render header
        $this->println($format, ...$headers);

        // Render bar
        $this->println($bar);

        // Render rows
        foreach ($rows as $row) {
            $format = '';
            foreach ($row as $value) {
                $format .= '| %-' . ($columnWidth +1 + strlen($value) - mb_strlen($value)) . 's';
            }
            $format .= '|';
            $this->println($format, ...$row);
        }

        // Render bar
        $this->println($bar);
    }

    private function truncate(string $str, int $length, $placeholder = '..'): string
    {
        if (mb_strlen($str) > $length) {
            return mb_substr($str, 0, $length - mb_strlen($placeholder)) . $placeholder;
        }

        return $str;
    }
}

M src/Renderer.php => src/Renderer.php +0 -74
@@ 3,56 3,6 @@ declare(strict_types=1);

namespace Thirdplace;

final class Renderer
{
    private array $config;
    private array $context;

    public function __construct(array $config = [])
    {
        $defaultConfig = [
            'templates' => './',
            // Default context that is always available in templates
            'context' => [],
        ];
        $this->config = array_merge($defaultConfig, $config);
        $this->context = $this->config['context'];
    }

    // This method not used much because each app typically customises it
    public function render(string $filePath, array $context = []): string
    {
        // might be bug here because the context contains values from previous calls
        $this->context = array_merge($this->context, $context);
        extract($this->context);
        ob_start();

        try {
            require $this->resolveFile($filePath);
        } catch (\Throwable $e) {
            ob_end_clean();
            throw $e;
        }

        return ob_get_clean();
    }

    private function resolveFile(string $filePath): string
    {
        if (is_file($filePath)) {
            return $filePath;
        }

        $candidate = sprintf('%s/%s', $this->config['templates'], $filePath);

        if (is_file($candidate)) {
            return $candidate;
        }

        throw new \Exception(sprintf('Unable to resolve file path: "%s"', $filePath));
    }
}

function render_template(string $template, array $context = [])
{
    extract($context);


@@ 89,27 39,3 @@ function sanitize(string $s): string
{
    return filter_var($s, FILTER_SANITIZE_STRING, FILTER_FLAG_NO_ENCODE_QUOTES);
}

/**
 * Truncate string
 */
function truncate(string $s, int $length = 100, $marker = ' [...]'): string
{
    $s = trim($s);

    if (mb_strlen($s) < $length) {
        return $s;
    }

    $naiveTruncate = mb_substr($s, 0, $length);

    $lastSpace = mb_strrpos($naiveTruncate, ' ');

    if ($lastSpace === false) {
        $lastSpace = $length;
    }

    $properTruncate = mb_substr($s, 0, $lastSpace);

    return $properTruncate . $marker;
}

M src/Session.php => src/Session.php +0 -7
@@ 3,13 3,6 @@ declare(strict_types=1);

namespace Thirdplace;

function flash(string $message)
{
    $messages = $_SESSION['messages'] ?? [];
    $messages[] = $message;
    $_SESSION['messages'] = $messages;
}

class SessionMiddleware
{
    private array $options;

D src/Shell.php => src/Shell.php +0 -26
@@ 1,26 0,0 @@
<?php
declare(strict_types=1);

namespace Thirdplace;

final class Shell
{
    public static function execute(string $command, array $arguments = []): array
    {
        $argumentString = '';
        foreach ($arguments as $argument) {
            $argumentString .= ' ' . escapeshellarg($argument);
        }

        $_ = exec("$command $argumentString", $result, $status);

        switch ($status) {
            case 0:
                return $result;
            case 127:
                throw new \Exception(sprintf('Command not found: "%s"', $command));
            default:
                throw new \Exception(sprintf('Unsuccessful: "%s"', $command));
        }
    }
}

M src/common.php => src/common.php +0 -48
@@ 4,51 4,3 @@ declare(strict_types=1);
namespace Thirdplace;

final class HttpException extends \Exception {}

function retry(int $times, \Closure $fn, int $sleep = 0)
{
    // todo: retry helper
}

function create_sane_stacktrace(\Throwable $e)
{
    // todo: maybe accept trace instead of ex?
    $stackTrace[] = sprintf('%s:%s', $e->getFile(), $e->getLine());
    foreach ($e->getTrace() as $trace) {
        $stackTrace[] = sprintf(
            '%s:%s',
            $trace['file'] ?? '(no file)',
            $trace['line'] ?? '(no line)'
        );
    }
    return array_reverse($stackTrace);
}

/**
 * Beware: the entropy cannot be deduced from the length
 */
function create_random_hex_string(int $length = 8)
{
    $bytes = (int) ceil($length / 2);
    // todo: remove annoying chars such as 0, 0, 1, l
    return substr(bin2hex(openssl_random_pseudo_bytes($bytes)), 0, $length);
}

class ExceptionMiddleware
{
    private $fn;

    public function __construct(callable $fn)
    {
        $this->fn = $fn;
    }

    function __invoke(Request $request, $next) {
        try {
            return $next($request);
        } catch (\Throwable $e) {
            // todo: consider wrapping this call in a try too?
            return ($this->fn)($e);
        }
    }
}

M src/http/CachingHttpClient.php => src/http/CachingHttpClient.php +1 -1
@@ 58,7 58,7 @@ final class CachingHttpClient implements HttpClient
            // Do we really want to cache this?
            // This logic might not belong here
            $this->cache->set($isCachedKey, true, 60 * 5);
            $this->cache->set($responseKey, response('', Response::SERVICE_UNAVAILABLE), 60 * 5);
            $this->cache->set($responseKey, new Response('', Response::SERVICE_UNAVAILABLE), 60 * 5);
            throw $e;
        }
    }

D src/http/Cookie.php => src/http/Cookie.php +0 -37
@@ 1,37 0,0 @@
<?php
declare(strict_types=1);

namespace Thirdplace;

final class Cookie
{
    public string $name;
    public string $value;

    public static function fromString(string $cookieString): self
    {
        $cookieString = ltrim($cookieString, '; ');
        $parts = explode(';', $cookieString);
        $nameAndValue = explode('=', $parts[0]);

        // should this be checking nameAndValue instead?
        if (!isset($parts[1])) {
            throw new \Exception(sprintf('Unable to parse cookie string: %s', $cookieString));
        }

        return new self($nameAndValue[0], $nameAndValue[1]);
    }

    private function __construct(string $name, string $value)
    {
        $this->name = $name;
        $this->value = $value;
    }

    public function send(): void
    {
        $options = [
        ];
        setcookie($this->name, $this->value, $options);
    }
}
\ No newline at end of file

M src/http/CurlHttpClient.php => src/http/CurlHttpClient.php +9 -7
@@ 75,14 75,16 @@ final class CurlHttpClient implements HttpClient
            if (preg_match('#^HTTP/(2|1.1|1.0)#', $rawHeader) || $rawHeader==="\r\n") {
                return $len;
            }
            // todo: maybe try..catch here
            $header = Header::fromString($rawHeader);

            if (!isset($responseHeaders[$header->name])) {
                $responseHeaders[$header->name] = [];
            $header = explode(':', $rawHeader);
            if (count($header) === 1) {
                return $len;
            }
            $responseHeaders[$header->name][] = $header->value;

            $name = mb_strtolower(trim($header[0]));
            $value = trim(implode(':', array_slice($header, 1)));
            if (!isset($responseHeaders[$name])) {
                $responseHeaders[$name] = [];
            }
            $responseHeaders[$name][] = $value;
            return $len;
        });


D src/http/Header.php => src/http/Header.php +0 -44
@@ 1,44 0,0 @@
<?php
declare(strict_types=1);

namespace Thirdplace;

final class Header
{
    public const APPLICATION_ATOM_XML   = 'application/atom+xml';
    public const APPLICATION_JSON       = 'application/json';
    public const APPLICATION_RSS_XML    = 'application/rss+xml';
    public const APPLICATION_XML        = 'application/xml';
    public const APPLICATION_X_RSS_XML  = 'application/x-rss+xml';
    public const TEXT_PLAIN             = 'text/plain';
    public const TEXT_XML               = 'text/xml';

    public string $name;
    public string $value;

    public static function fromNameAndValue(string $name, string $value): self
    {
        return new self($name, $value);
    }

    public static function fromString(string $rawHeader): self
    {
        $header = explode(':', $rawHeader);

        if (count($header) === 1) {
            // todo: throw HeaderException
            throw new \Exception(sprintf('Invalid header string: "%s"', $rawHeader));
        }

        $name = mb_strtolower(trim($header[0]));
        $value = trim(implode(':', array_slice($header, 1)));

        return new self($name, $value);
    }

    private function __construct(string $name, string $value)
    {
        $this->name = $name;
        $this->value = $value;
    }
}
\ No newline at end of file

M src/http/Request.php => src/http/Request.php +2 -3
@@ 31,15 31,14 @@ final class Request
            throw new HttpException('Failed to read raw body');
        }

        $self->headers = [];
        if (function_exists('getallheaders')) {
            foreach (\getallheaders() as $name => $value) {
                $self->headers[strtolower($name)] = $value;
            }
        } else {
            $self->headers = [];
        }

        $self->attributes   = [];
        $self->attributes = [];

        return $self;
    }

M src/http/Response.php => src/http/Response.php +4 -31
@@ 3,30 3,6 @@ declare(strict_types=1);

namespace Thirdplace;

function response(string $body = '', int $code = 200): Response
{
    return new Response($body, $code);
}

function text(string $body, int $code = 200): Response
{
    return response($body, $code)->withHeader('content-type', 'text/plain');
}

function json(array $body, int $code = 200): Response
{
    $json = Json::encode($body);
    return response($json, $code)->withHeader('content-type', 'application/json');
}

function redirect(string $url, string $flash = '')
{
    if ($flash !== '') {
        flash($flash);
    }
    return response('', 302)->withHeader('location', $url);
}

final class Response
{
    public const OK                     = 200;


@@ 69,8 45,6 @@ final class Response
    public int $code;

    private array $headers;

    /** @var Cookie[] */
    private array $cookies;

    /**


@@ 121,11 95,10 @@ final class Response
        return $lastHeader ?: $default;
    }

    public function withCookie(Cookie $cookie): self
    public function withCookie($cookie): self
    {
        $clone = clone $this;
        $clone->cookies[$cookie->name] = $cookie;
        return $clone;
        // TODO
        throw new \Exception('Not implemented');
    }

    public function cookie(string $name): ?Cookie


@@ 142,7 115,7 @@ final class Response
        }

        foreach ($this->cookies as $cookie) {
            $cookie->send();
            // TODO: setcookie
        }

        print $this->body;

D src/logger/MailgunHandler.php => src/logger/MailgunHandler.php +0 -50
@@ 1,50 0,0 @@
<?php
declare(strict_types=1);

namespace Thirdplace;

final class MailgunHandler
{
    private HttpClient $client;
    private array $config;

    public function __construct(
        HttpClient $client,
        array $config
    ) {
        $this->client = $client;
        $this->config = $config;
    }

    public function __invoke(array $record): void
    {
        if (isset($record['context']['e'])) {
            $record['context']['e'] = create_sane_stacktrace($record['context']['e']);
        }
        $subject = sprintf(
            "[%s] %s.%s %s",
            $record['created_at']->format('Y-m-d H:i:s'),
            $record['name'],
            $record['level_name'],
            str_replace(["\n", "\r"], '\n', $record['message'])
        );
        $context = Json::encode($record['context']) ?: '["Unable to json encode context"]';
        $url = sprintf('https://api.mailgun.net/v3/%s/messages', $this->config['domain']);
        $response = $this->client->request('POST', $url, [
            'auth' => [
                'user' => 'api',
                'pass' => $this->config['key'],
            ],
            'body' => [
                'from'      => $this->config['from'],
                'to'        => $this->config['to'],
                'subject'   => truncate($subject),
                'text'      => "$subject\n$context",
            ],
        ]);
        if ($response->code !== 200) {
            // Perhaps lets not throw since we might already be inside an exception?
            throw new \Exception("MailgunHandler: $response->body");
        }
    }
}

M src/logger/StreamHandler.php => src/logger/StreamHandler.php +1 -13
@@ 14,13 14,6 @@ final class StreamHandler

    public function __invoke(array $record): void
    {
        if (isset($record['context']['e'])) {
            $e = $record['context']['e'];
            // todo: improve message
            $record['context']['message'] = $e->getMessage();
            $record['context']['code'] = $e->getCode();
            $record['context']['trace'] = create_sane_stacktrace($e);
        }
        if ($record['context'] === []) {
            $record['context'] = '';
        } else {


@@ 42,11 35,6 @@ final class StreamHandler
            $this->stream = fopen($this->stream, 'a');
        }

        if (!fwrite($this->stream, $text)) {
            // Maybe drop this last effort to write to error log
            if (!error_log('Unable to write log record: ' . $record['message'])) {
                // todo: write to stderr or stdout?
            }
        }
        $bytes = fwrite($this->stream, $text);
    }
}

A tests/UrlTest.php => tests/UrlTest.php +36 -0
@@ 0,0 1,36 @@
<?php

declare(strict_types=1);

namespace Thirdplace;

use PHPUnit\Framework\TestCase;

class UrlTest extends TestCase
{
    public function test()
    {
        $this->assertEquals('https://example.com:81/foo?bar=baz#kek', (string)url('https://example.com:81/foo?bar=baz#kek'));
        $this->assertEquals('https://example.com', (string)url('https://example.com'));
        $this->assertEquals('https://example.com/foo', (string)url('https://example.com/foo'));
        $this->assertEquals('https://example.com/foo/', (string)url('https://example.com/foo/'));
        $this->assertEquals('https://example.com', (string)url('https://example.com/'));
        $this->assertEquals('https://example.com', (string)url('https://example.com//'));
        $this->assertEquals('https://example.com', (string)url('https://example.com/./'));
        $this->assertEquals('https://example.com', (string)url('https://example.com//./'));
        $this->assertEquals('https://example.com', (string)url('https://example.com/.///'));
        $this->assertEquals('https://example.com/a', (string)url('https://example.com/../a'));
        $this->assertEquals('https://example.com', (string)url('https://example.com/foo/..'));
        $this->assertEquals('https://example.com/foo', (string)url('https://example.com/foo/bar/..'));
        $this->assertEquals('https://example.com/foo?bar=', (string)url('https://example.com/foo?bar'));
        $this->assertEquals('https://example.com/?baz=', (string)url('https://example.com/foo/..?baz='));
        $this->assertEquals('https://example.com/foo/bar', (string)url('https://example.com/foo/bar/baz/..'));
        $this->assertEquals('https://example.com/foo', (string)url('https://example.com/foo/bar/../baz/..'));
        //assert$this->Equals('https://example.com', (string)url('https://example.com/foo/bar/../..'));
        $this->assertEquals('https://example.com', (string)url('https://example.com/../..'));
        $this->assertEquals('https://example.com/foo?foo=bar', (string) url('https://example.com/foo?foo=bar'));
        $this->assertEquals('https://blog.zulip.com/2022/05/05/public-access-option', (string) url('https://blog.zulip.com/2022/05/05/public-access-option'));
        $this->assertEquals('https://kevincox.ca/feed.atom', (string) url('https://kevincox.ca/2022/05/../../feed.atom'));

    }
}