Skip to content

[FEATURE] POST subscriptions route #129

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -10,6 +10,7 @@ This project adheres to [Semantic Versioning](https://semver.org/).
- Run the system test on Travis (#113)
- Add security headers to the default response (#110)
- Whitelist BadRequestHttpException so that messages are not sanitized (#108)
- REST API endpoint for adding a subscriber to a list

### Changed

70 changes: 70 additions & 0 deletions docs/Api/RestApi.apib
Original file line number Diff line number Diff line change
@@ -392,3 +392,73 @@ object containing the following key-value pairs:
"code": 422,
"message": "Some fields invalid: email, confirmed, html_email"
}

# Subscriptions

Resources related to subscriptions.

All requests in this group need to be authenticated with a valid session key
provided as basic auth password. (The basic auth user name can be any string.)

## Subscriptions [/subscriptions]

### Create a new subscription [POST]

Given a valid authentication, this will generate a subscription, which means add a member to a list.
It takes a JSON object containing the following key-value pairs:

+ `subscriber_id` (integer): ID of the subscriber (required)
+ `subscriber_list_id` (integer): ID of the list (required)

+ Response 201 (application/json)

+ Body

{
"creation_date": "2020-01-09T18:44:27+00:00",
}

+ Response 403 (application/json)

+ Body

{
"code": 403,
"message": "No valid session key was provided as basic auth password."
}

+ Response 409 (application/json)

+ Body

{
"code": 409,
"message": "This resource already exists."
}

+ Response 422 (application/json)

+ Body

{
"code": 422,
"message": "Some fields invalid: subscriber_id, subscriber_list_id"
}

+ Response 422 (application/json)

+ Body

{
"code": 422,
"message": "subscriber_id not found: 42"
}

+ Response 422 (application/json)

+ Body

{
"code": 422,
"message": "subscriber_list_id not found: 42"
}
130 changes: 130 additions & 0 deletions src/Controller/SubscriptionController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);

namespace PhpList\RestBundle\Controller;

use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use FOS\RestBundle\Controller\FOSRestController;
use FOS\RestBundle\Routing\ClassResourceInterface;
use FOS\RestBundle\View\View;
use PhpList\Core\Domain\Model\Subscription\Subscription;
use PhpList\Core\Domain\Repository\Messaging\SubscriberListRepository;
use PhpList\Core\Domain\Repository\Subscription\SubscriberRepository;
use PhpList\Core\Domain\Repository\Subscription\SubscriptionRepository;
use PhpList\Core\Security\Authentication;
use PhpList\RestBundle\Controller\Traits\AuthenticationTrait;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;

/**
* This controller provides REST API access to subscriptions.
*
* @author Matthieu Robin <matthieu@macolu.org>
*/
class SubscriptionController extends FOSRestController implements ClassResourceInterface
{
use AuthenticationTrait;

/**
* @var SubscriberRepository
*/
private $subscriberRepository = null;

/**
* @var SubscriberListRepository
*/
private $subscriberListRepository;

/**
* @var SubscriptionRepository
*/
private $subscriptionRepository;

/**
* @param Authentication $authentication
* @param SubscriberRepository|null $subscriberRepository
* @param SubscriberListRepository $subscriberListRepository
* @param SubscriptionRepository $subscriptionRepository
*/
public function __construct(
Authentication $authentication,
SubscriberRepository $subscriberRepository,
SubscriberListRepository $subscriberListRepository,
SubscriptionRepository $subscriptionRepository
) {
$this->authentication = $authentication;
$this->subscriberRepository = $subscriberRepository;
$this->subscriberListRepository = $subscriberListRepository;
$this->subscriptionRepository = $subscriptionRepository;
}

/**
* Creates a new subscription.
*
* @param Request $request
*
* @return View
*
* @throws UnprocessableEntityHttpException
* @throws ConflictHttpException
*/
public function postAction(Request $request): View
{
$this->requireAuthentication($request);

$this->validateSubscription($request);

$subscriber = $this->subscriberRepository->findOneById($request->get('subscriber_id'));
if ($subscriber === null) {
throw new UnprocessableEntityHttpException(
'subscriber_id not found: '.$request->get('subscriber_id'),
null,
1598917596
);
}

$subscriberList = $this->subscriberListRepository->findOneById($request->get('subscriber_list_id'));
if ($subscriberList === null) {
throw new UnprocessableEntityHttpException(
'subscriber_list_id not found: '.$request->get('subscriber_list_id'),
null,
1598917574
);
}

$subscription = new Subscription();
$subscription->setSubscriber($subscriber);
$subscription->setSubscriberList($subscriberList);

try {
$this->subscriptionRepository->save($subscription);
} catch (UniqueConstraintViolationException $e) {
throw new ConflictHttpException('This resource already exists.', null, 1598918448);
}

return View::create()->setStatusCode(Response::HTTP_CREATED)->setData($subscription);
}

private function validateSubscription(Request $request)
{
/** @var string[] $invalidFields */
$invalidFields = [];
if (filter_var($request->get('subscriber_id'), FILTER_VALIDATE_INT) === false) {
$invalidFields[] = 'subscriber_id';
}

if (filter_var($request->get('subscriber_list_id'), FILTER_VALIDATE_INT) === false) {
$invalidFields[] = 'subscriber_list_id';
}

if (!empty($invalidFields)) {
throw new UnprocessableEntityHttpException(
'Some fields invalid:' . implode(', ', $invalidFields),
null,
1598914359
);
}
}
}
223 changes: 223 additions & 0 deletions tests/Integration/Controller/SubscriptionControllerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
<?php
declare(strict_types=1);

namespace PhpList\RestBundle\Tests\Integration\Controller;

use PhpList\Core\Domain\Model\Subscription\Subscription;
use PhpList\Core\Domain\Repository\Subscription\SubscriptionRepository;
use PhpList\RestBundle\Controller\SubscriptionController;

/**
* Testcase.
*
* @author Matthieu Robin <matthieu@macolu.org>
*/
class SubscriptionControllerTest extends AbstractControllerTest
{
/**
* @var string
*/
const SUBSCRIBER_TABLE_NAME = 'phplist_user_user';

/**
* @var string
*/
const SUBSCRIBER_LIST_TABLE_NAME = 'phplist_list';

/**
* @var string
*/
const SUBSCRIPTION_TABLE_NAME = 'phplist_listuser';

/**
* @var SubscriptionRepository
*/
private $subscriptionRepository = null;

protected function setUp()
{
$this->setUpDatabaseTest();
$this->setUpWebTest();

$this->subscriptionRepository = $this->bootstrap->getContainer()
->get(SubscriptionRepository::class);
}

/**
* @test
*/
public function controllerIsAvailableViaContainer()
{
static::assertInstanceOf(
SubscriptionController::class,
$this->client->getContainer()->get(SubscriptionController::class)
);
}

/**
* @test
*/
public function getSubscriptionsIsNotAllowed()
{
$this->client->request('get', '/api/v2/subscriptions');

$this->assertHttpMethodNotAllowed();
}

/**
* @test
*/
public function postSubscriptionsWithoutSessionKeyReturnsForbiddenStatus()
{
$this->jsonRequest('post', '/api/v2/subscriptions');

$this->assertHttpForbidden();
}

/**
* @test
*/
public function postSubscriptionsWithValidSessionKeyAndMinimalValidSubscriberDataCreatesResource()
{
$this->getDataSet()->addTable(static::SUBSCRIBER_TABLE_NAME, __DIR__ . '/Fixtures/Subscriber.csv');
$this->getDataSet()->addTable(static::SUBSCRIBER_LIST_TABLE_NAME, __DIR__ . '/Fixtures/SubscriberList.csv');
$this->applyDatabaseChanges();
$this->touchDatabaseTable(static::SUBSCRIPTION_TABLE_NAME);

$jsonData = [
'subscriber_id' => 1,
'subscriber_list_id' => 1
];

$this->authenticatedJsonRequest('post', '/api/v2/subscriptions', [], [], [], json_encode($jsonData));

$this->assertHttpCreated();
}

/**
* @test
*/
public function postSubscriptionsWithValidSessionKeyAndMinimalValidDataReturnsCreationDate()
{
$this->getDataSet()->addTable(static::SUBSCRIBER_TABLE_NAME, __DIR__ . '/Fixtures/Subscriber.csv');
$this->getDataSet()->addTable(static::SUBSCRIBER_LIST_TABLE_NAME, __DIR__ . '/Fixtures/SubscriberList.csv');
$this->applyDatabaseChanges();
$this->touchDatabaseTable(static::SUBSCRIPTION_TABLE_NAME);

$jsonData = [
'subscriber_id' => 1,
'subscriber_list_id' => 1
];

$this->authenticatedJsonRequest('post', '/api/v2/subscriptions', [], [], [], json_encode($jsonData));

$responseContent = $this->getDecodedJsonResponseContent();

static::assertGreaterThan(0, $responseContent['creation_date']);
}

/**
* @test
*/
public function postSubscriptionsWithValidSessionKeyAndValidDataCreatesSubscription()
{
$this->getDataSet()->addTable(static::SUBSCRIBER_TABLE_NAME, __DIR__ . '/Fixtures/Subscriber.csv');
$this->getDataSet()->addTable(static::SUBSCRIBER_LIST_TABLE_NAME, __DIR__ . '/Fixtures/SubscriberList.csv');
$this->applyDatabaseChanges();
$this->touchDatabaseTable(static::SUBSCRIPTION_TABLE_NAME);


$jsonData = [
'subscriber_id' => 1,
'subscriber_list_id' => 1
];

$this->authenticatedJsonRequest('post', '/api/v2/subscriptions', [], [], [], json_encode($jsonData));

static::assertInstanceOf(Subscription::class, $this->subscriptionRepository->findOneBy([]));
}

/**
* @test
*/
public function postSubscriptionsWithValidSessionKeyAndExistingSubscriberAndSubscriberListCreatesConflictStatus()
{
$this->getDataSet()->addTable(static::SUBSCRIBER_TABLE_NAME, __DIR__ . '/Fixtures/Subscriber.csv');
$this->getDataSet()->addTable(static::SUBSCRIBER_LIST_TABLE_NAME, __DIR__ . '/Fixtures/SubscriberList.csv');
$this->getDataSet()->addTable(static::SUBSCRIPTION_TABLE_NAME, __DIR__ . '/Fixtures/Subscription.csv');
$this->applyDatabaseChanges();

$jsonData = [
'subscriber_id' => 1,
'subscriber_list_id' => 2
];

$this->authenticatedJsonRequest('post', '/api/v2/subscriptions', [], [], [], json_encode($jsonData));

$this->assertHttpConflict();
}

/**
* @return array[]
*/
public function nonexistentSubscriberOrSubscriberListDataProvider()
{
return [
[2, 3], // nonexistent subscriber
[1, 4] // nonexistent subscriberList
];
}

/**
* @test
* @dataProvider nonexistentSubscriberOrSubscriberListDataProvider
* @param int $subscriberId
* @param int $subscriberListId
*/
public function postSubscriptionsWithValidSessionKeyAndNonexistentSubscriberOrListCreatesUnprocessableEntityStatus(
$subscriberId,
$subscriberListId
) {
$this->getDataSet()->addTable(static::SUBSCRIBER_TABLE_NAME, __DIR__ . '/Fixtures/Subscriber.csv');
$this->getDataSet()->addTable(static::SUBSCRIBER_LIST_TABLE_NAME, __DIR__ . '/Fixtures/SubscriberList.csv');
$this->getDataSet()->addTable(static::SUBSCRIPTION_TABLE_NAME, __DIR__ . '/Fixtures/Subscription.csv');
$this->applyDatabaseChanges();

$jsonData = [
'subscriber_id' => $subscriberId,
'subscriber_list_id' => $subscriberListId
];

$this->authenticatedJsonRequest('post', '/api/v2/subscriptions', [], [], [], json_encode($jsonData));

$this->assertHttpUnprocessableEntity();
}

/**
* @return array[][]
*/
public function invalidSubscriptionDataProvider(): array
{
return [
'no data' => [[]],
'subscriber_id is null' => [['subscriber_id' => null, 'subscriber_list_id' => 1]],
'subscriber_id is a string' => [['subscriber_id' => 'foo', 'subscriber_list_id' => 1]],
'subscriber_id as boolean' => [['subscriber_id' => true, 'subscriber_list_id' => 1]],
'subscriber_list_id is null' => [['subscriber_id' => 1, 'subscriber_list_id' => null]],
'subscriber_list_id is a string' => [['subscriber_id' => 1, 'subscriber_list_id' => 'foo']],
'subscriber_list_id as boolean' => [['subscriber_id' => 1, 'subscriber_list_id' => true]],
];
}

/**
* @test
* @dataProvider invalidSubscriptionDataProvider
* @param array[] $jsonData
*/
public function postSubscribersWithInvalidDataCreatesUnprocessableEntityStatus(array $jsonData)
{
$this->authenticatedJsonRequest('post', '/api/v2/subscriptions', [], [], [], json_encode($jsonData));

$this->assertHttpUnprocessableEntity();
}
}