gdbots/pbjx-bundle-php

View on GitHub
src/Controller/PbjxController.php

Summary

Maintainability
D
2 days
Test Coverage
<?php
declare(strict_types=1);

namespace Gdbots\Bundle\PbjxBundle\Controller;

use Gdbots\Bundle\PbjxBundle\PbjxTokenSigner;
use Gdbots\Pbj\Exception\GdbotsPbjException;
use Gdbots\Pbj\Message;
use Gdbots\Pbj\MessageResolver;
use Gdbots\Pbj\Schema;
use Gdbots\Pbj\SchemaCurie;
use Gdbots\Pbj\SchemaId;
use Gdbots\Pbj\Util\ClassUtil;
use Gdbots\Pbj\Util\StringUtil;
use Gdbots\Pbjx\Exception\RequestHandlingFailed;
use Gdbots\Pbjx\Pbjx;
use Gdbots\Pbjx\Util\StatusCodeUtil;
use Gdbots\Schemas\Pbjx\Enum\Code;
use Gdbots\Schemas\Pbjx\Enum\HttpCode;
use Gdbots\Schemas\Pbjx\EnvelopeV1;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;

final class PbjxController
{
    private Pbjx $pbjx;
    private PbjxTokenSigner $signer;
    private array $allowedMethods = ['POST'];

    /**
     * An array of curies (or regexes) that will
     * NOT require PbjxToken validation.
     *
     * @var string[]
     */
    private array $bypassTokenValidation = [];

    public function __construct(
        Pbjx            $pbjx,
        PbjxTokenSigner $signer,
        bool            $allowGetRequest = false,
        array           $bypassTokenValidation = []
    ) {
        $this->pbjx = $pbjx;
        $this->signer = $signer;
        $this->bypassTokenValidation = $bypassTokenValidation;

        if ($allowGetRequest) {
            $this->allowedMethods = ['GET', 'POST'];
        }
    }

    public function handleAction(Request $request): Message
    {
        $request->attributes->set('pbjx_redact_error_message', false);
        $envelope = EnvelopeV1::create();
        if (!$this->isRequestMethodOk($envelope, $request) || !$this->isContentTypeOk($envelope, $request)) {
            return $envelope;
        }

        if ($request->isMethod('GET')) {
            $json = StringUtil::urlsafeB64Decode($request->query->get('pbj'));
        } elseif (str_starts_with($request->headers->get('Content-Type'), 'multipart/form-data')) {
            $json = $request->request->get('pbj');
        } else {
            $json = $request->getContent();
        }

        $json = (string)$json;
        $data = json_decode($json, true) ?: [];
        if (!$this->isJsonOk($envelope, $request)) {
            return $envelope;
        }

        $pbjxCategory = $request->attributes->get('pbjx_category');
        if (str_contains($pbjxCategory, '_')) {
            $pbjxCategory = str_replace('_', '', $pbjxCategory);
            $request->attributes->set('pbjx_category', $pbjxCategory);
        }

        try {
            $expectedCurie = SchemaCurie::fromString(sprintf(
                '%s:%s:%s:%s',
                $request->attributes->get('pbjx_vendor'),
                $request->attributes->get('pbjx_package'),
                $pbjxCategory,
                $request->attributes->get('pbjx_message')
            ));

            $request->attributes->set('pbjx_curie', $expectedCurie->toString());
            $schemaId = isset($data[Schema::PBJ_FIELD_NAME]) ? SchemaId::fromString($data[Schema::PBJ_FIELD_NAME]) : null;

            if (null === $schemaId) {
                $class = MessageResolver::resolveCurie($expectedCurie);
                $schema = $class::schema();
                $schemaId = $schema->getId();
            } else {
                $class = MessageResolver::resolveId($schemaId);
                $schema = $class::schema();
            }

            if ($schemaId->getCurie() !== $expectedCurie) {
                throw new \InvalidArgumentException(
                    sprintf(
                        'The resolved schema [%s] doesn\'t match expected curie [%s].',
                        $schemaId->toString(),
                        $expectedCurie->toString()
                    )
                );
            }
        } catch (\Throwable $e) {
            return $envelope
                ->set('code', Code::INVALID_ARGUMENT->value)
                ->set('http_code', HttpCode::HTTP_UNPROCESSABLE_ENTITY)
                ->set('error_name', 'UnprocessableEntity')
                ->set('error_message', $e->getMessage());
        }

        $request->attributes->set('pbjx_input', $data);
        $request->attributes->set('pbjx_bind_unrestricted', $request->attributes->getBoolean('pbjx_bind_unrestricted'));

        try {
            $message = $class::fromArray($data);
            $message->set('ctx_correlator_ref', $envelope->generateMessageRef());
        } catch (\Throwable $e) {
            return $envelope
                ->set('code', Code::INVALID_ARGUMENT->value)
                ->set('http_code', HttpCode::HTTP_UNPROCESSABLE_ENTITY)
                ->set('error_name', 'UnprocessableEntity')
                ->set('error_message', $e->getMessage());
        }

        $request->attributes->set(
            'pbjx_redact_error_message',
            !$request->attributes->getBoolean('pbjx_console')
        );

        if (!$this->isPbjxTokenOk($envelope, $request, $json)) {
            return $envelope;
        }

        // allows for functional tests/postman tests/etc. to post pbjx but
        // not actually run them.  this is most important for commands/events
        // which can change state but for request, you'd typically not use
        // dry run because you need to get a response in order to make assertions.
        if ($request->headers->has('x-pbjx-dry-run')) {
            return $envelope
                ->set('code', Code::OK->value)
                ->set('http_code', HttpCode::HTTP_ACCEPTED)
                ->set('message_ref', $message->generateMessageRef());
        }

        if ($schema->hasMixin('gdbots:pbjx:mixin:command')) {
            return $this->handleCommand($envelope, $request, $message);
        }

        if ($schema->hasMixin('gdbots:pbjx:mixin:event')) {
            return $this->handleEvent($envelope, $request, $message);
        }

        if ($schema->hasMixin('gdbots:pbjx:mixin:request')) {
            return $this->handleRequest($envelope, $request, $message);
        }

        $request->attributes->set('pbjx_redact_error_message', false);
        return $envelope
            ->set('code', Code::INVALID_ARGUMENT->value)
            ->set('http_code', HttpCode::HTTP_UNPROCESSABLE_ENTITY)
            ->set('error_name', 'UnprocessableEntity')
            ->set(
                'error_message',
                sprintf('This service does not allow you to submit [%s] messages.', $schemaId->toString())
            );
    }

    private function handleCommand(Message $envelope, Request $request, Message $command): Message
    {
        try {
            $this->pbjx->send($command);
        } catch (\Throwable $e) {
            return $this->handleException($envelope, $request, $command, $e);
        }

        $envelope
            ->set('code', Code::OK->value)
            ->set('http_code', HttpCode::HTTP_ACCEPTED)
            ->set('message_ref', $command->generateMessageRef());

        if ($request->attributes->getBoolean('pbjx_console')) {
            $envelope->set('message', $command);
        }

        return $envelope;
    }

    private function handleEvent(Message $envelope, Request $request, Message $event): Message
    {
        try {
            $this->pbjx->publish($event);
        } catch (\Throwable $e) {
            return $this->handleException($envelope, $request, $event, $e);
        }

        $envelope
            ->set('code', Code::OK->value)
            ->set('http_code', HttpCode::HTTP_ACCEPTED)
            ->set('message_ref', $event->generateMessageRef());

        if ($request->attributes->getBoolean('pbjx_console')) {
            $envelope->set('message', $event);
        }

        return $envelope;
    }

    private function handleRequest(Message $envelope, Request $request, Message $pbjxRequest): Message
    {
        try {
            $response = $this->pbjx->request($pbjxRequest);
        } catch (\Throwable $e) {
            return $this->handleException($envelope, $request, $pbjxRequest, $e);
        }

        return $envelope
            ->set('code', Code::OK->value)
            ->set('http_code', HttpCode::HTTP_OK)
            //->set('etag', $response->get('etag'))
            ->set('message_ref', $response->generateMessageRef())
            ->set('message', $response);
    }

    private function handleException(Message $envelope, Request $request, Message $message, \Throwable $exception): Message
    {
        if ($exception instanceof HttpExceptionInterface) {
            $httpCode = HttpCode::tryFrom($exception->getStatusCode()) ?: HttpCode::UNKNOWN;
            $code = StatusCodeUtil::httpToVendor($httpCode);
            $errorName = ClassUtil::getShortName($exception);
            $errorMessage = $exception->getMessage();
        } elseif ($exception instanceof RequestHandlingFailed) {
            $response = $exception->getResponse();
            $code = Code::tryFrom($response->get('error_code')) ?: Code::UNKNOWN;
            $httpCode = StatusCodeUtil::vendorToHttp($code);
            $errorName = $response->get('error_name', ClassUtil::getShortName($exception));
            $errorMessage = $response->get('error_message', $exception->getMessage());
        } elseif ($exception instanceof GdbotsPbjException) {
            $code = Code::INVALID_ARGUMENT;
            $httpCode = HttpCode::HTTP_UNPROCESSABLE_ENTITY;
            $errorName = ClassUtil::getShortName($exception);
            $errorMessage = $exception->getMessage();
            // these error messages are safe to show as they only indicate schema problems
            $request->attributes->set('pbjx_redact_error_message', false);
        } else {
            $code = Code::tryFrom(
                $exception->getCode() > 0 ? $exception->getCode() : Code::INVALID_ARGUMENT->value
            ) ?: Code::INVALID_ARGUMENT;
            $httpCode = StatusCodeUtil::vendorToHttp($code);
            $errorName = ClassUtil::getShortName($exception);
            $errorMessage = $exception->getMessage();
        }

        return $envelope
            ->set('code', $code->value)
            ->set('http_code', $httpCode)
            ->set('error_name', $errorName)
            ->set('error_message', $errorMessage);
    }

    private function isPbjxTokenOk(Message $envelope, Request $request, string $content): bool
    {
        if ($request->attributes->getBoolean('pbjx_console')) {
            // no tokens used on the console
            return true;
        }

        $curie = $request->attributes->get('pbjx_curie');
        foreach ($this->bypassTokenValidation as $pattern) {
            if ('all' === $pattern || $curie === $pattern) {
                return true;
            }

            if (preg_match('/' . trim($pattern, '/') . '/', $curie)) {
                return true;
            }
        }

        $token = $request->headers->get('x-pbjx-token');
        if (empty($token)) {
            $envelope
                ->set('code', Code::INVALID_ARGUMENT->value)
                ->set('http_code', HttpCode::HTTP_BAD_REQUEST)
                ->set('error_name', 'MissingPbjxToken')
                ->set('error_message', 'Missing x-pbjx-token header.');
            return false;
        }

        if ($request->headers->has('Authorization')) {
            $bearer = trim(str_ireplace('bearer ', '', $request->headers->get('Authorization')));
            $this->signer->addKey('bearer', $bearer);
        }

        if ($request->headers->has('x-pbjx-nonce')) {
            $this->signer->addKey('nonce', $request->headers->get('x-pbjx-nonce'));
        }

        try {
            $this->signer->validate($content, $request->getUri(), $token);
            return true;
        } catch (\Throwable $e) {
            $envelope
                ->set('code', Code::INVALID_ARGUMENT->value)
                ->set('http_code', HttpCode::HTTP_BAD_REQUEST)
                ->set('error_name', 'InvalidPbjxToken')
                ->set('error_message', $e->getMessage());
            return false;
        } finally {
            $this->signer->removeKey('bearer');
            $this->signer->removeKey('nonce');
        }
    }

    private function isRequestMethodOk(Message $envelope, Request $request): bool
    {
        if (in_array($request->getMethod(), $this->allowedMethods)) {
            return true;
        }

        $envelope
            ->set('code', Code::UNIMPLEMENTED->value)
            ->set('http_code', HttpCode::HTTP_METHOD_NOT_ALLOWED)
            ->set('error_name', 'MethodNotAllowed')
            ->set(
                'error_message',
                sprintf('You can only use HTTP [%s] on this service.', implode(',', $this->allowedMethods))
            );

        return false;
    }

    private function isContentTypeOk(Message $envelope, Request $request): bool
    {
        if ($request->query->has('callback') && $request->isMethod('GET')) {
            // jsonp request, don't enforce
            $request->attributes->set('_jsonp_enabled', true);
            return true;
        }

        $request->attributes->set('_jsonp_enabled', false);
        $contentType = $request->headers->get('Content-Type');

        if (str_starts_with($contentType, 'multipart/form-data') || str_starts_with($contentType, 'application/json')) {
            return true;
        }

        $envelope
            ->set('code', Code::INVALID_ARGUMENT->value)
            ->set('http_code', HttpCode::HTTP_NOT_ACCEPTABLE)
            ->set('error_name', 'NotAcceptable')
            ->set('error_message', sprintf(
                'This service supports [application/json] or [multipart/form-data], you provided [%s].',
                $contentType
            ));

        return false;
    }

    private function isJsonOk(Message $envelope, Request $request): bool
    {
        if (JSON_ERROR_NONE === json_last_error()) {
            return true;
        }

        $envelope
            ->set('code', Code::INVALID_ARGUMENT->value)
            ->set('http_code', HttpCode::HTTP_UNSUPPORTED_MEDIA_TYPE)
            ->set('error_name', 'UnsupportedMediaType')
            ->set('error_message', 'Invalid json: ' . json_last_error_msg());

        return false;
    }
}