Refactor routing/controller layer for better separation of concerns

This commit is contained in:
Jeremy Dormitzer 2019-01-23 09:28:15 -05:00
parent 31ace0ec11
commit 04920a86a2
14 changed files with 217 additions and 376 deletions

View File

@ -6,7 +6,7 @@ require_once __DIR__ . '/../vendor/autoload.php';
use ActivityPub\Auth\AuthListener;
use ActivityPub\Auth\SignatureListener;
use ActivityPub\Config\ActivityPubModule;
use ActivityPub\Http\ControllerResolver;
use ActivityPub\Http\Router;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\Tools\SchemaTool;
use Symfony\Component\HttpFoundation\Request;
@ -14,6 +14,7 @@ use Symfony\Component\HttpKernel\HttpKernel;
use Symfony\Component\EventDispatcher;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Controller\ArgumentResolver;
use Symfony\Component\HttpKernel\Controller\ControllerResolver;
use Symfony\Component\HttpKernel\EventListener\ExceptionListener;
class ActivityPub
@ -49,11 +50,12 @@ class ActivityPub
}
$dispatcher = $this->module->get( EventDispatcher::class );
$dispatcher->addSubscriber( $this->module->get( Router::class ) );
$dispatcher->addSubscriber( $this->module->get( AuthListener::class ) );
$dispatcher->addSubscriber( $this->module->get( SignatureListener::class ) );
$dispatcher->addSubscriber( new ExceptionListener() );
$controllerResolver = $this->module->get( ControllerResolver::class );
$controllerResolver = new ControllerResolver();
$argumentResolver = new ArgumentResolver();
$kernel = new HttpKernel(

View File

@ -4,12 +4,11 @@ namespace ActivityPub\Config;
use ActivityPub\Auth\AuthListener;
use ActivityPub\Auth\AuthService;
use ActivityPub\Auth\SignatureListener;
use ActivityPub\Controllers\GetObjectController;
use ActivityPub\Controllers\InboxController;
use ActivityPub\Controllers\OutboxController;
use ActivityPub\Controllers\GetController;
use ActivityPub\Controllers\PostController;
use ActivityPub\Crypto\HttpSignatureService;
use ActivityPub\Database\PrefixNamingStrategy;
use ActivityPub\Http\ControllerResolver;
use ActivityPub\Http\Router;
use ActivityPub\Objects\ContextProvider;
use ActivityPub\Objects\CollectionsService;
use ActivityPub\Objects\IdProvider;
@ -104,22 +103,18 @@ class ActivityPubModule
->addArgument( new Reference( ObjectsService::class ) )
->addArgument( new Reference( RandomProvider::class ) );
$this->injector->register( GetObjectController::class, GetObjectController::class )
$this->injector->register( GetController::class, GetController::class )
->addArgument( new Reference( ObjectsService::class ) )
->addArgument( new Reference( CollectionsService::class ) )
->addArgument( new Reference( AuthService::class ) );
$this->injector->register( InboxController::class, InboxController::class )
->addArgument( new Reference( EventDispatcher::class ) );
$this->injector->register( PostController::class, PostController::class )
->addArgument( new Reference( EventDispatcher::class ) )
->addArgument( new Reference( ObjectsService::class ) );
$this->injector->register( OutboxController::class, OutboxController::class )
->addArgument( new Reference( EventDispatcher::class ) );
$this->injector->register( ControllerResolver::class, ControllerResolver::class )
->addArgument( new Reference( ObjectsService::class ) )
->addArgument( new Reference( GetObjectController::class ) )
->addArgument( new Reference( InboxController::class ) )
->addArgument( new Reference( OutboxController::class ) );
$this->injector->register( Router::class, Router::class )
->addArgument( new Reference( GetController::class ) )
->addArgument( new Reference( PostController::class ) );
}
/**

View File

@ -11,9 +11,9 @@ use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* The GetObjectController is responsible for rendering ActivityPub objects as JSON
* The GetController is responsible for rendering ActivityPub objects as JSON
*/
class GetObjectController
class GetController
{
/**

View File

@ -1,51 +0,0 @@
<?php
namespace ActivityPub\Controllers;
use ActivityPub\Activities\InboxActivityEvent;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\HttpFoundation\Request;
/**
* The InboxController handles POST requests to an inbox
*/
class InboxController
{
private $eventDispatcher;
public function __construct( EventDispatcher $EventDispatcher )
{
$this->eventDispatcher = $EventDispatcher;
}
/**
* Handles the inbox request and returns a proper Response
*
* @param Request $request The request
* @return Response
*/
public function handle( Request $request )
{
if ( ! $request->attributes->has( 'actor' ) ) {
throw new UnauthorizedHttpException();
}
$actor = $request->attributes->get( 'actor' );
$inboxId = $this->getUriWithoutQuery( $request );
if ( ! $actor->hasField( 'inbox' ) || $actor['inbox']['id'] !== $inboxId ) {
throw new UnauthorizedHttpException();
}
$activity = $request->attributes->get( 'activity' );
$event = new InboxActivityEvent( $activity, $actor, $request );
$this->eventDispatcher->dispatch( InboxActivityEvent::NAME, $event );
}
private function getUriWithoutQuery( Request $request )
{
$uri = $request->getUri();
$queryPos = strpos( $uri, '?' );
if ( $queryPos !== false ) {
$uri = substr( $uri, 0, $queryPos );
}
return $uri;
}
}
?>

View File

@ -1,52 +0,0 @@
<?php
namespace ActivityPub\Controllers;
use ActivityPub\Activities\OutboxActivityEvent;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
class OutboxController
{
/**
* @var EventDispatcher
*/
private $eventDispatcher;
public function __construct( EventDispatcher $eventDispatcher )
{
$this->eventDispatcher = $eventDispatcher;
}
/**
* Handles the outbox request and returns a proper response
*
* @param Request $request The incoming request
* @return Response
*/
public function handle( Request $request )
{
if ( ! $request->attributes->has( 'actor' ) ) {
throw new UnauthorizedHttpException();
}
$actor = $request->attributes->get( 'actor' );
$outboxId = $this->getUriWithoutQuery( $request );
if ( ! $actor->hasField( 'outbox' ) || $actor['outbox']['id'] !== $outboxId ) {
throw new UnauthorizedHttpException();
}
$activity = $request->attributes->get( 'activity' );
$event = new OutboxActivityEvent( $activity, $actor, $request );
$this->eventDispatcher->dispatch( OutboxActivityEvent::NAME, $event );
}
private function getUriWithoutQuery( Request $request )
{
$uri = $request->getUri();
$queryPos = strpos( $uri, '?' );
if ( $queryPos !== false ) {
$uri = substr( $uri, 0, $queryPos );
}
return $uri;
}
}
?>

View File

@ -0,0 +1,111 @@
<?php
namespace ActivityPub\Controllers;
use ActivityPub\Activities\InboxActivityEvent;
use ActivityPub\Activities\OutboxActivityEvent;
use ActivityPub\Objects\ObjectsService;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
/**
* The PostController is responsible for handling incoming ActivityPub POST requests
*/
class PostController
{
/**
* @var EventDispatcher
*/
private $eventDispatcher;
/**
* @var ObjectsService
*/
private $objectsService;
public function __construct( EventDispatcher $eventDispatcher,
ObjectsService $objectsService )
{
$this->eventDispatcher = $eventDispatcher;
$this->objectsService = $objectsService;
}
/**
* Handles an incoming POST request
*
* Either dispatches an inbox/outbox activity event or throws the appropriate
* HTTP error.
* @param Request $request The request
*/
public function handle( Request $request )
{
$uri = $this->getUriWithoutQuery( $request );
$object = $this->objectsService->dereference( $uri );
if ( ! $object ) {
throw new NotFoundHttpException;
}
$actorWithInbox = $this->objectWithField( 'inbox', $uri );
if ( $actorWithInbox ) {
if ( ! $request->attributes->has( 'signed' ) ||
! $this->authorized( $request, $actorWithInbox ) ) {
throw new UnauthorizedHttpException();
}
$activity = json_decode( $request->getContent(), true );
if ( ! $activity ) {
throw new BadRequestHttpException();
}
$event = new InboxActivityEvent( $activity, $actorWithInbox, $request );
$this->eventDispatcher->dispatch( InboxActivityEvent::NAME, $event );
return;
}
$actorWithOutbox = $this->objectWithField( 'outbox', $uri );
if ( $actorWithOutbox ) {
if ( ! $this->authorized( $request, $actorWithOutbox ) ) {
throw new UnauthorizedHttpException();
}
$activity = json_decode( $request->getContent(), true );
if ( ! $activity ) {
throw new BadRequestHttpException();
}
$event = new OutboxActivityEvent( $activity, $actorWithOutbox, $request );
$this->eventDispatcher->dispatch( OutboxActivityEvent::NAME, $event );
return;
}
throw new MethodNotAllowedHttpException( array( Request::METHOD_GET ) );
}
private function authorized( Request $request, ActivityPubObject $activityActor )
{
if ( ! $request->attributes->has( 'actor' ) ) {
return false;
}
$requestActor = $request->attributes->get( 'actor' );
if ( $requestActor['id'] !== $activityActor['id'] ) {
return false;
}
return true;
}
private function objectWithField( string $name, string $value )
{
$results = $this->objectsService->query( array( $name => $value ) );
if ( count( $results ) === 0 ) {
return false;
}
return $results[0];
}
private function getUriWithoutQuery( Request $request )
{
$uri = $request->getUri();
$queryPos = strpos( $uri, '?' );
if ( $queryPos !== false ) {
$uri = substr( $uri, 0, $queryPos );
}
return $uri;
}
}
?>

View File

@ -1,93 +0,0 @@
<?php
namespace ActivityPub\Http;
use ActivityPub\Controllers\GetObjectController;
use ActivityPub\Controllers\InboxController;
use ActivityPub\Controllers\OutboxController;
use ActivityPub\Objects\ObjectsService;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Controller\ControllerResolverInterface;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ControllerResolver implements ControllerResolverInterface
{
private $objectsService;
private $getObjectController;
private $inboxController;
private $outboxController;
public function __construct( ObjectsService $objectsService,
GetObjectController $getObjectController,
InboxController $inboxController,
OutboxController $outboxController )
{
$this->objectsService = $objectsService;
$this->getObjectController = $getObjectController;
$this->inboxController = $inboxController;
$this->outboxController = $outboxController;
}
/**
* Returns true if an object with a field named $name with value $value exists
*
* @param string $name The field name to look for
* @param string $value The field value to look for
* @return ActivityPubObject|bool The first result, or false if there are no results
*/
private function objectWithField( string $name, string $value )
{
$results = $this->objectsService->query( array( $name => $value ) );
if ( count( $results ) === 0 ) {
return false;
}
return $results[0];
}
public function getController( Request $request )
{
if ( $request->getMethod() == Request::METHOD_GET ) {
return array( $this->getObjectController, 'handle' );
} else if ( $request->getMethod() == Request::METHOD_POST ) {
$uri = $this->getUriWithoutQuery( $request );
$actorWithInbox = $this->objectWithField( 'inbox', $uri );
if ( $actorWithInbox ) {
$activity = json_decode( $request->getContent(), true );
if ( ! $activity || ! array_key_exists( 'type', $activity ) ) {
throw new BadRequestHttpException( '"type" field not found' );
}
$request->attributes->set( 'activity', $activity );
return array( $this->inboxController, 'handle' );
} else {
$actorWithOutbox = $this->objectWithField( 'outbox', $uri );
if ( $actorWithOutbox ) {
$activity = json_decode( $request->getContent(), true );
if ( ! $activity || ! array_key_exists( 'type', $activity ) ) {
throw new BadRequestHttpException( '"type" field not found' );
}
$request->attributes->set( 'activity', $activity );
return array( $this->outboxController, 'handle' );
} else {
throw new NotFoundHttpException();
}
}
} else {
throw new MethodNotAllowedHttpException( array(
Request::METHOD_GET,
Request::METHOD_POST,
) );
}
}
private function getUriWithoutQuery( Request $request )
{
$uri = $request->getUri();
$queryPos = strpos( $uri, '?' );
if ( $queryPos !== false ) {
$uri = substr( $uri, 0, $queryPos );
}
return $uri;
}
}
?>

60
src/Http/Router.php Normal file
View File

@ -0,0 +1,60 @@
<?php
namespace ActivityPub\Http;
use ActivityPub\Controllers\GetController;
use ActivityPub\Controllers\PostController;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Symfony\Component\HttpKernel\KernelEvents;
class Router implements EventSubscriberInterface
{
/**
* @var GetController
*/
private $getController;
/**
* @var PostController
*/
private $postController;
public static function getSubscribedEvents()
{
return array(
KernelEvents::REQUEST => 'route',
);
}
public function __construct( GetController $getController,
PostController $postController )
{
$this->getController = $getController;
$this->postController = $postController;
}
/**
* Routes the request by setting the _controller attribute
*
* @param GetResponseEvent $event The request event
*/
public function route( GetResponseEvent $event )
{
$request = $event->getRequest();
if ( $request->getMethod() === Request::METHOD_GET ) {
$request->attributes->set(
'_controller', array( $this->getController, 'handle' )
);
} else if ( $request->getMethod() === Request::METHOD_POST ) {
$request->attributes->set(
'_controller', array( $this->postController, 'handle' )
);
} else {
throw new MethodNotAllowedHttpException( array(
Request::METHOD_GET, Request::METHOD_POST
) );
}
}
}
?>

View File

@ -2,7 +2,7 @@
namespace ActivityPub\Test\Config;
use ActivityPub\Config\ActivityPubModule;
use ActivityPub\Http\ControllerResolver;
use ActivityPub\Http\Router;
use Doctrine\ORM\EntityManager;
use PHPUnit\Framework\TestCase;
@ -27,10 +27,9 @@ class ActivityPubModuleTest extends TestCase
$this->assertNotNull( $entityManager );
$this->assertInstanceOf( EntityManager::class, $entityManager );
$controllerResolver = $this->module->get( ControllerResolver::class );
$this->assertNotNull( $controllerResolver );
$this->assertInstanceOf( ControllerResolver::class, $controllerResolver );
$router = $this->module->get( Router::class );
$this->assertNotNull( $router );
$this->assertInstanceOf( Router::class, $router );
}
}
?>

View File

@ -2,7 +2,7 @@
namespace ActivityPub\Test\Controllers;
use ActivityPub\Auth\AuthService;
use ActivityPub\Controllers\GetObjectController;
use ActivityPub\Controllers\GetController;
use ActivityPub\Entities\ActivityPubObject;
use ActivityPub\Entities\Field;
use ActivityPub\Objects\ContextProvider;
@ -14,7 +14,7 @@ use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use PHPUnit\Framework\TestCase;
class GetObjectControllerTest extends TestCase
class GetControllerTest extends TestCase
{
const OBJECTS = array(
'https://example.com/objects/1' => array(
@ -51,7 +51,7 @@ class GetObjectControllerTest extends TestCase
),
);
private $getObjectController;
private $getController;
public function setUp()
{
@ -66,7 +66,7 @@ class GetObjectControllerTest extends TestCase
$authService = new AuthService();
$contextProvider = new ContextProvider();
$collectionsService = new CollectionsService( 4, $authService, $contextProvider );
$this->getObjectController = new GetObjectController(
$this->getController = new GetController(
$objectsService, $collectionsService, $authService
);
}
@ -74,7 +74,7 @@ class GetObjectControllerTest extends TestCase
public function testItRendersPersistedObject()
{
$request = Request::create( 'https://example.com/objects/1' );
$response = $this->getObjectController->handle( $request );
$response = $this->getController->handle( $request );
$this->assertNotNull( $response );
$this->assertEquals(
json_encode( self::OBJECTS['https://example.com/objects/1'] ),
@ -87,21 +87,21 @@ class GetObjectControllerTest extends TestCase
{
$request = Request::create( 'https://example.com/objects/notreal' );
$this->expectException( NotFoundHttpException::class );
$this->getObjectController->handle( $request );
$this->getController->handle( $request );
}
public function testItDeniesAccess()
{
$request = Request::create( 'https://example.com/objects/2' );
$this->expectException( UnauthorizedHttpException::class );
$this->getObjectController->handle( $request );
$this->getController->handle( $request );
}
public function testItAllowsAccessToAuthedActor()
{
$request = Request::create( 'https://example.com/objects/2' );
$request->attributes->set( 'actor', 'https://example.com/actor/1' );
$response = $this->getObjectController->handle( $request );
$response = $this->getController->handle( $request );
$this->assertNotNull( $response );
$this->assertEquals(
json_encode( self::OBJECTS['https://example.com/objects/2'] ),
@ -114,7 +114,7 @@ class GetObjectControllerTest extends TestCase
{
$request = Request::create( 'https://example.com/objects/2' );
$request->attributes->set( 'actor', 'https://example.com/actor/2' );
$response = $this->getObjectController->handle( $request );
$response = $this->getController->handle( $request );
$this->assertNotNull( $response );
$this->assertEquals(
json_encode( self::OBJECTS['https://example.com/objects/2'] ),
@ -126,7 +126,7 @@ class GetObjectControllerTest extends TestCase
public function testItAllowsAccessToNoAudienceObject()
{
$request = Request::create( 'https://example.com/objects/3' );
$response = $this->getObjectController->handle( $request );
$response = $this->getController->handle( $request );
$this->assertNotNull( $response );
$this->assertEquals(
json_encode( self::OBJECTS['https://example.com/objects/3'] ),
@ -138,7 +138,7 @@ class GetObjectControllerTest extends TestCase
public function testItDisregardsQueryParams()
{
$request = Request::create( 'https://example.com/objects/1?foo=bar&baz=qux' );
$response = $this->getObjectController->handle( $request );
$response = $this->getController->handle( $request );
$this->assertNotNull( $response );
$this->assertEquals(
json_encode( self::OBJECTS['https://example.com/objects/1'] ),

View File

@ -1,14 +0,0 @@
<?php
namespace ActivityPub\Test\Controllers;
use PHPUnit\Framework\TestCase;
class InboxControllerTest extends TestCase
{
public function testInboxController()
{
// TODO implement me
$this->assertTrue( false );
}
}
?>

View File

@ -3,7 +3,7 @@ namespace ActivityPub\Test\Controllers;
use PHPUnit\Framework\TestCase;
class OutboxControllerTest extends TestCase
class PostControllerTest extends TestCase
{
public function testOutboxController()
{

View File

@ -1,130 +0,0 @@
<?php
namespace ActivityPub\Test\Http;
use ActivityPub\Controllers\Inbox\DefaultInboxController;
use ActivityPub\Controllers\Outbox\DefaultOutboxController;
use ActivityPub\Controllers\GetObjectController;
use ActivityPub\Controllers\InboxController;
use ActivityPub\Controllers\OutboxController;
use ActivityPub\Http\ControllerResolver;
use ActivityPub\Objects\ObjectsService;
use ActivityPub\Test\TestUtils\TestUtils;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ControllerResolverTest extends TestCase
{
const INBOX_URI = 'https://example.com/inbox';
const OUTBOX_URI = 'https://example.com/outbox';
private $controllerResolver;
private $getObjectController;
private $inboxController;
private $outboxController;
public function setUp()
{
$objectsService = $this->createMock( ObjectsService::class );
$objectsService->method( 'query' )->will(
$this->returnCallback( function ( $query ) {
if ( array_key_exists( 'inbox', $query ) &&
$query['inbox'] == self::INBOX_URI ) {
return array( TestUtils::objectFromArray( array(
'id' => 'https://example.com/actor/1',
'inbox' => array(
'id' => 'https://example.com/actor/1/inbox',
),
) ) );
}
if ( array_key_exists( 'outbox', $query ) &&
$query['outbox'] == self::OUTBOX_URI ) {
return array( TestUtils::objectFromArray( array(
'id' => 'https://example.com/actor/1',
'outbox' => array(
'id' => 'https://example.com/actor/1/outbox',
),
) ) );
}
return array();
})
);
$this->getObjectController = $this->createMock( GetObjectController::class );
$this->inboxController = $this->createMock( InboxController::class );
$this->outboxController = $this->createMock( OutboxController::class );
$this->controllerResolver = new ControllerResolver(
$objectsService,
$this->getObjectController,
$this->inboxController,
$this->outboxController
);
}
private function createRequestWithBody( $uri, $method, $body )
{
$json = json_encode( $body );
return Request::create($uri, $method, array(), array(), array(), array(), $json);
}
public function testItReturnsGetObjectController()
{
$request = Request::create( 'https://example.com/object', Request::METHOD_GET );
$controller = $this->controllerResolver->getController( $request );
$this->assertIsCallable( $controller );
$this->assertEquals( array( $this->getObjectController, 'handle' ), $controller );
}
public function testItChecksForType()
{
$request = Request::create( 'https://example.com/inbox', Request::METHOD_POST );
$this->expectException( BadRequestHttpException::class );
$controller = $this->controllerResolver->getController( $request );
}
public function testItReturnsInboxController()
{
$request = $this->createRequestWithBody(
'https://example.com/inbox', Request::METHOD_POST, array( 'type' => 'Foo' )
);
$controller = $this->controllerResolver->getController( $request );
$this->assertTrue( $request->attributes->has( 'activity' ) );
$this->assertEquals(
array( 'type' => 'Foo' ), $request->attributes->get( 'activity' )
);
$this->assertEquals( array( $this->inboxController, 'handle' ), $controller );
}
public function testItReturnsOutboxController()
{
$request = $this->createRequestWithBody(
'https://example.com/outbox', Request::METHOD_POST, array( 'type' => 'Foo' )
);
$controller = $this->controllerResolver->getController( $request );
$this->assertTrue( $request->attributes->has( 'activity' ) );
$this->assertEquals(
array( 'type' => 'Foo' ), $request->attributes->get( 'activity' )
);
$this->assertEquals( array( $this->outboxController, 'handle' ), $controller );
}
public function testItDisallowsPostToInvalidUrl()
{
$request = $this->createRequestWithBody(
'https://example.com/object', Request::METHOD_POST, array( 'type' => 'Foo' )
);
$this->expectException( NotFoundHttpException::class );
$this->controllerResolver->getController( $request );
}
public function testItDisallowsNonGetPostMethods()
{
$request = $this->createRequestWithBody(
'https://example.com/inbox', Request::METHOD_PUT, array( 'type' => 'Foo' )
);
$this->expectException( MethodNotAllowedHttpException::class );
$this->controllerResolver->getController( $request );
}
}
?>

14
test/Http/RouterTest.php Normal file
View File

@ -0,0 +1,14 @@
<?php
namespace ActivityPub\Test\Http;
use PHPUnit\Framework\TestCase;
class RouterTest extends TestCase
{
public function testRouter()
{
// TODO implement me
$this->assertTrue( false );
}
}
?>