When you post a job, you will want to have the greatest exposure possible. If your job is syndicated on a lot of small sites, you will have a better chance to find the right person. That’s the power of the long tail. Affiliates will be able to publish the latest posted jobs on their sites thanks to the API we will develop along this day.
As per day 2 requirements:
"Story F7: An affiliate retrieves the current active job list"
Note! There was a change in day 3: in file
src/Entity/Affiliate.php
methodsetCategories
was replaced by two methodsaddCategory
andremoveCategory
. Please check if you have these methods in Affiliate class and compare it with file from day 3.
Let’s create a new fixture file for the affiliates:
namespace App\DataFixtures;
use App\Entity\Affiliate;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Common\Persistence\ObjectManager;
class AffiliateFixtures extends Fixture implements DependentFixtureInterface
{
/**
* @param ObjectManager $manager
*
* @return void
*/
public function load(ObjectManager $manager) : void
{
$affiliateSensioLabs = new Affiliate();
$affiliateSensioLabs->setUrl('http://www.sensiolabs.com/');
$affiliateSensioLabs->setEmail('[email protected]');
$affiliateSensioLabs->setActive(true);
$affiliateSensioLabs->setToken('sensio_labs');
$affiliateSensioLabs->addCategory($manager->merge($this->getReference('category-programming')));
$affiliateKNPLabs = new Affiliate();
$affiliateKNPLabs->setUrl('http://www.knplabs.com/');
$affiliateKNPLabs->setEmail('[email protected]');
$affiliateKNPLabs->setActive(true);
$affiliateKNPLabs->setToken('knp_labs');
$affiliateKNPLabs->addCategory($manager->merge($this->getReference('category-programming')));
$affiliateKNPLabs->addCategory($manager->merge($this->getReference('category-design')));
$manager->persist($affiliateSensioLabs);
$manager->persist($affiliateKNPLabs);
$manager->flush();
}
/**
* @return array
*/
public function getDependencies(): array
{
return [
CategoryFixtures::class,
];
}
}
In the fixtures file, tokens are hardcoded to simplify the testing, but when an actual user applies for an account, the token will need to be generated.
Create a new listener in src/EventListener
folder:
namespace App\EventListener;
use App\Entity\Affiliate;
use Doctrine\ORM\Event\LifecycleEventArgs;
class AffiliateTokenListener
{
/**
* @param LifecycleEventArgs $args
*/
public function prePersist(LifecycleEventArgs $args)
{
$entity = $args->getEntity();
if (!$entity instanceof Affiliate) {
return;
}
if (!$entity->getToken()) {
$entity->setToken(\bin2hex(\random_bytes(10)));
}
}
}
Register this listener in config/services.yaml
:
# ...
services:
# ...
App\EventListener\AffiliateTokenListener:
tags:
- { name: doctrine.event_listener, event: prePersist }
Now you can reload fixtures:
bin/console doctrine:fixtures:load
To create the job API we gonna use bunch of two bundles: JMSSerializerBundle and FOSRestBundle. JMSSerializerBundle is used to easily serialize and deserialize data, and FOSRestBundle provides various tools to rapidly develop RESTful API’s.
First install JMSSerializerBundle:
composer require jms/serializer-bundle ^3.1
During the installation you will be asked if recipe from contrib repository should be applied:
Our suggestion is to answer y
(Yes).
Thanks to Symfony Flex and this recipe bundle will be automatically connected in config/bundles.php
and next configuration files will be created:
config/packages/dev/jms_serializer.yaml
config/packages/jms_serializer.yaml
Next install FOSRestBundle:
composer require friendsofsymfony/rest-bundle ^2.5
There will be the same question and after that new config file will appear: config/packages/fos_rest.yaml
.
We have bundles installed and initial configuration created!
It is good practice to separate API routes and controllers as we did with admin routes:
- all controllers will be placed in
src/Controller/API
- all routes will start with
/api/v1/
Create first API controller in folder src/Controller/API
:
namespace App\Controller\API;
use FOS\RestBundle\Controller\FOSRestController;
class JobController extends FOSRestController
{
}
Notice that this controller extends FOSRestController
class from FOSRestBundle
package.
This parent controller provides some helper methods that we will use.
And modify config/routes/annotations.yaml
:
controllers:
resource: ../../src/Controller/
type: annotation
+ api_controllers:
+ resource: ../../src/Controller/API/
+ type: annotation
+ prefix: /api/v1/
This change prepend /api/v1/
to all routes from src/Controller/API/
folder and we don’t have to track it for each API route.
Routes configuration is done and let’s create our first action:
namespace App\Controller\API;
use App\Entity\Affiliate;
use App\Entity\Job;
use Doctrine\ORM\EntityManagerInterface;
use FOS\RestBundle\Controller\FOSRestController;
use FOS\RestBundle\Controller\Annotations as Rest;
use Symfony\Component\HttpFoundation\Response;
class JobController extends FOSRestController
{
/**
* @Rest\Get("/{token}/jobs", name="api.job.list")
*
* @param Affiliate $affiliate
* @param EntityManagerInterface $em
*
* @return Response
*/
public function getJobsAction(Affiliate $affiliate, EntityManagerInterface $em) : Response
{
$jobs = $em->getRepository(Job::class)->findActiveJobs();
return $this->handleView($this->view($jobs, Response::HTTP_OK));
}
}
Do small adjustments in configuration file config/packages/fos_rest.yaml
of the FOSRestBundle:
fos_rest:
format_listener:
rules:
- { path: ^/api, prefer_extension: true, fallback_format: json, priorities: [ json ] }
- { path: ^/, prefer_extension: true, fallback_format: html, priorities: [ html, '*/*'] }
Here we have two rules:
- rule for routes starting with
/api
- response will be serialized into json - rule for all other routes - response will be formatted with twig, as it was before
It was the only thing you need to configure in FOSRestBundle.
Now try to open http://127.0.0.1/api/v1/sensio_labs/jobs link. You should see something similar:
We use next extension for Chrome to beautify JSON output: JSON Formatter.
What we did here:
- fetched affiliate by token from route (in our case token is
sensio_labs
) - used
view
method to createView
object with all jobs and 200 response code inside - used
handleView
method to convertView
object into response object
What is not done yet:
- check if affiliate is active
- return only jobs related to affiliate
- modify job serialization process
For now active and inactive affiliates are able to perform request, and we have to fix it.
The better place to put this logic is repository class. Create repository class in src/Repository/AffiliateRepository.php
folder:
namespace App\Repository;
use Doctrine\ORM\EntityRepository;
class AffiliateRepository extends EntityRepository
{
}
and link it in Affiliate
entity:
namespace App\Entity;
// ...
/**
* @ORM\Entity(repositoryClass="App\Repository\AffiliateRepository")
* ...
*/
class Affiliate
{
// ...
}
add method in repository that will fetch active user by given token:
namespace App\Repository;
use App\Entity\Affiliate;
use Doctrine\ORM\EntityRepository;
class AffiliateRepository extends EntityRepository
{
/**
* @param string $token
*
* @return Affiliate|null
*/
public function findOneActiveByToken(string $token) : ?Affiliate
{
return $this->createQueryBuilder('a')
->where('a.active = :active')
->andWhere('a.token = :token')
->setParameter('active', true)
->setParameter('token', $token)
->getQuery()
->getOneOrNullResult();
}
}
and adjust controller to use this method from repository:
namespace App\Controller\API;
// ...
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Entity;
class JobController extends FOSRestController
{
/**
* @Rest\Get("/{token}/jobs", name="api.job.list")
*
* @Entity("affiliate", expr="repository.findOneActiveByToken(token)")
*
* @param Affiliate $affiliate
* @param EntityManagerInterface $em
*
* @return Response
*/
public function getJobsAction(Affiliate $affiliate, EntityManagerInterface $em) : Response
{
// ...
}
}
Try to modify active
field in database and you will observe that inactive user are not allowed to perform requests.
If you noticed, we used findActiveJobs
method to return active jobs. But what if affiliate is linked just to one category?
Create another method in JobRepository
that will get affiliate into consideration:
namespace App\Repository;
// ...
use App\Entity\Affiliate;
class JobRepository extends EntityRepository
{
// ...
/**
* @param Affiliate $affiliate
*
* @return Job[]
*/
public function findActiveJobsForAffiliate(Affiliate $affiliate)
{
return $this->createQueryBuilder('j')
->leftJoin('j.category', 'c')
->leftJoin('c.affiliates', 'a')
->where('a.id = :affiliate')
->andWhere('j.expiresAt > :date')
->andWhere('j.activated = :activated')
->setParameter('affiliate', $affiliate)
->setParameter('date', new \DateTime())
->setParameter('activated', true)
->orderBy('j.expiresAt', 'DESC')
->getQuery()
->getResult();
}
}
Here we joined jobs with categories and affiliates. The result includes only jobs that have relation with affiliate.
Call new method in controller:
namespace App\Controller\API;
// ...
class JobController extends FOSRestController
{
// ...
public function getJobsAction(Affiliate $affiliate, EntityManagerInterface $em) : Response
{
$jobs = $em->getRepository(Job::class)->findActiveJobsForAffiliate($affiliate);
return $this->handleView($this->view($jobs, Response::HTTP_OK));
}
}
Insert some test data in affiliates_categories
table and check the result.
If you look closely to the result, you will see that everything from the database is returned, including jobs and affiliates from the category of a job and that’s too much. To limit the returned data we will use some methods from the JMSSerializerBundle we installed earlier.
Open the src/Entity/Job.php
entity file and add the following annotations and new methods:
// ...
use JMS\Serializer\Annotation as JMS;
/**
* ...
*
* @JMS\ExclusionPolicy("all")
*/
class Job
{
// ...
/**
* ...
*
* @JMS\Expose()
* @JMS\Type("int")
*/
private $id;
/**
* ...
*
* @JMS\Expose()
* @JMS\Type("string")
*/
private $type;
/**
* ...
*
* @JMS\Expose()
* @JMS\Type("string")
*/
private $company;
/**
* ...
*/
private $logo;
/**
* ...
*
* @JMS\Expose()
* @JMS\Type("string")
*/
private $url;
/**
* ...
*
* @JMS\Expose()
* @JMS\Type("string")
*/
private $position;
/**
* ...
*
* @JMS\Expose()
* @JMS\Type("string")
*/
private $location;
/**
* ...
*
* @JMS\Expose()
* @JMS\Type("string")
*/
private $description;
/**
* ...
*
* @JMS\Expose()
* @JMS\Type("string")
*/
private $howToApply;
/**
* ...
*/
private $token;
/**
* ...
*/
private $public;
/**
* ...
*/
private $activated;
/**
* ...
*/
private $email;
/**
* ...
*
* @JMS\Expose()
* @JMS\Type("DateTime")
*/
private $expiresAt;
/**
* ...
*/
private $createdAt;
/**
* ...
*/
private $updatedAt;
/**
* ...
*/
private $category;
// ...
/**
* @JMS\VirtualProperty
* @JMS\SerializedName("logo_path")
*
* @return string|null
*/
public function getLogoPath()
{
return $this->getLogo() ? 'uploads/jobs/' . $this->getLogo() : null;
}
/**
* @JMS\VirtualProperty
* @JMS\SerializedName("category_name")
*
* @return string
*/
public function getCategoryName()
{
return $this->getCategory()->getName();
}
}
We added a couple of annotations that start with @JMS
. All these annotations are provided by JMSSerializerBundle
.
The first one is: @JMS\ExclusionPolicy("all")
. This annotation with value all
tells to JMSSerializerBundle
that all properties are excluded for serialization by default and only properties marked with @JMS\Expose
will be serialized/unserialized.
Also we added annotation @JMS\Type
to all serialized properties. This annotation force a certain format to be used. The list of supported types can e found here.
For computed properties, such as "logo_path" and "category_name", we added special set of annotations:
@JMS\VirtualProperty
@JMS\SerializedName
The first annotation indicates that the data returned by the method should appear like a property of the object.
The second annotation is used to define the serialized name for the result.
You can read more about used annotation and others on the JMSSerializerBundle documentation page.
Now that the web service is ready to be used, let’s create the account creation form for affiliates.
First create the controller:
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
class AffiliateController extends AbstractController
{
}
Next create form type that will be used in this controller:
namespace App\Form;
use App\Entity\Affiliate;
use App\Entity\Category;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\UrlType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Email;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
class AffiliateType extends AbstractType
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('url', UrlType::class, [
'required' => false,
'constraints' => [
new Length(['max' => 255]),
]
])
->add('email', EmailType::class, [
'constraints' => [
new NotBlank(),
new Email()
]
])
->add('categories', EntityType::class, [
'class' => Category::class,
'choice_label' => 'name',
'multiple' => true,
'expanded' => true,
'constraints' => [
new NotBlank(),
]
]);
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => Affiliate::class,
]);
}
}
It’s quite simple form with just 3 fields: url, email and categories.
Create new action in controller and build form:
namespace App\Controller;
// ...
use App\Entity\Affiliate;
use App\Form\AffiliateType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\HttpFoundation\Response;
class AffiliateController extends AbstractController
{
/**
* Creates a new affiliate entity.
*
* @Route("/affiliate/create", name="affiliate.create", methods={"GET", "POST"})
*
* @param Request $request
* @param EntityManagerInterface $em
*
* @return RedirectResponse|Response
*/
public function create(Request $request, EntityManagerInterface $em) : Response
{
$affiliate = new Affiliate();
$form = $this->createForm(AffiliateType::class, $affiliate);
return $this->render('affiliate/create.html.twig', [
'form' => $form->createView(),
]);
}
}
render the form:
{% extends 'base.html.twig' %}
{% block body %}
<h1>Become an Affiliate</h1>
{{ form_start(form, {'attr': {'novalidate': 'novalidate'}}) }}
{{ form_widget(form) }}
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<button type="submit" class="btn btn-default">Create</button>
</div>
</div>
{{ form_end(form) }}
{% endblock %}
After an affiliate will submit the form, they will be redirected to the new wait page. Add the wait
action in the AffiliateController.php
file:
namespace App\Controller;
// ...
class AffiliateController extends AbstractController
{
// ...
/**
* Shows the wait affiliate message.
*
* @Route("/affiliate/wait", name="affiliate.wait", methods={"GET"})
*
* @return Response
*/
public function wait()
{
return $this->render('affiliate/wait.html.twig');
}
}
and template templates/affiliate/wait.html.twig
:
{% extends 'base.html.twig' %}
{% block body %}
<div class="jumbotron">
<h3 class="text-center">
<b>Your affiliate account has been created</b>
</h3>
<p>Thank you! You will receive an email with your affiliate token as soon as your account will be activated.</p>
</div>
{% endblock %}
Tie it all together in create
action:
namespace App\Controller;
// ...
class AffiliateController extends AbstractController
{
/**
* Creates a new affiliate entity.
*
* @Route("/affiliate/create", name="affiliate.create", methods={"GET", "POST"})
*
* @param Request $request
* @param EntityManagerInterface $em
*
* @return RedirectResponse|Response
*/
public function create(Request $request, EntityManagerInterface $em) : Response
{
$affiliate = new Affiliate();
$form = $this->createForm(AffiliateType::class, $affiliate);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$affiliate->setActive(false);
$em->persist($affiliate);
$em->flush();
return $this->redirectToRoute('affiliate.wait');
}
return $this->render('affiliate/create.html.twig', [
'form' => $form->createView(),
]);
}
// ...
}
Last, add new button in the header before "Post a Job" button to point to the affiliate form (templates/base.html.twig
):
<li style="margin-right: 10px">
<div>
<a href="{{ path('affiliate.create') }}" class="btn btn-default navbar-btn">Affiliates</a>
</div>
</li>
Now everything should work fine and the affiliates will be able to register to our site.
Affiliates will create requests and Admin have to validate them somehow.
To add a new affiliate section in our admin we will need to create a new controller:
namespace App\Controller\Admin;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
class AffiliateController extends AbstractController
{
}
and the list action:
namespace App\Controller\Admin;
// ...
use App\Entity\Affiliate;
use Doctrine\ORM\EntityManagerInterface;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\HttpFoundation\Response;
class AffiliateController extends AbstractController
{
/**
* Lists all affiliate entities.
*
* @Route("/admin/affiliates/{page}",
* name="admin.affiliate.list",
* methods="GET",
* defaults={"page": 1},
* requirements={"page" = "\d+"}
* )
*
* @param EntityManagerInterface $em
* @param PaginatorInterface $paginator
* @param int $page
*
* @return Response
*/
public function list(EntityManagerInterface $em, PaginatorInterface $paginator, int $page) : Response
{
$affiliates = $paginator->paginate(
$em->getRepository(Affiliate::class)->createQueryBuilder('a'),
$page,
$this->getParameter('max_per_page'),
[
PaginatorInterface::DEFAULT_SORT_FIELD_NAME => 'a.active',
PaginatorInterface::DEFAULT_SORT_DIRECTION => 'ASC',
]
);
return $this->render('admin/affiliate/list.html.twig', [
'affiliates' => $affiliates,
]);
}
}
Note that we ordered affiliates by active field. Not validated affiliates will be rendered in the top of the list.
Create template to display the list templates/admin/affiliate/list.html.twig
:
{% extends 'admin/base.html.twig' %}
{% block body %}
<table class="table">
<thead>
<tr class="active">
<th>Email</th>
<th>URL</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for affiliate in affiliates %}
<tr>
<td>{{ affiliate.email }}</td>
<td>{{ affiliate.url }}</td>
<td>
{% if affiliate.active %}
<span class="label label-success">Yes</span>
{% else %}
<span class="label label-danger">No</span>
{% endif %}
</td>
<td class="text-nowrap">
<ul class="list-inline">
<li>
{% if affiliate.active %}
<a href="#" class="btn btn-default">Deactivate</a>
{% else %}
<a href="#" class="btn btn-default">Activate</a>
{% endif %}
</li>
</ul>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="navigation text-center">
{{ knp_pagination_render(affiliates) }}
</div>
{% endblock %}
Add link to list action in left menu after Jobs button (templates/admin/base.html.twig
):
<li role="presentation" {% if currentRouteName starts with 'admin.affiliate.' %}class="active"{% endif %}>
<a href="{{ path('admin.affiliate.list') }}">Affiliates</a>
</li>
Create action to activate affiliate:
namespace App\Controller\Admin;
// ...
class AffiliateController extends AbstractController
{
// ...
/**
* Activate affiliate.
*
* @Route("/admin/affiliate/{id}/activate",
* name="admin.affiliate.activate",
* methods="GET",
* requirements={"id" = "\d+"}
* )
*
* @param EntityManagerInterface $em
* @param Affiliate $affiliate
*
* @return Response
*/
public function activate(EntityManagerInterface $em, Affiliate $affiliate) : Response
{
$affiliate->setActive(true);
$em->flush();
return $this->redirectToRoute('admin.affiliate.list');
}
}
and for deactivation:
namespace App\Controller\Admin;
// ...
class AffiliateController extends AbstractController
{
// ...
/**
* Deactivate affiliate.
*
* @Route("/admin/affiliate/{id}/deactivate",
* name="admin.affiliate.deactivate",
* methods="GET",
* requirements={"id" = "\d+"}
* )
*
* @param EntityManagerInterface $em
* @param Affiliate $affiliate
*
* @return Response
*/
public function deactivate(EntityManagerInterface $em, Affiliate $affiliate) : Response
{
$affiliate->setActive(false);
$em->flush();
return $this->redirectToRoute('admin.affiliate.list');
}
}
link buttons with actions in templates/admin/affiliate/list.html.twig
:
- <a href="#" class="btn btn-default">Deactivate</a>
+ <a href="{{ path('admin.affiliate.deactivate', {id: affiliate.id}) }}" class="btn btn-default">Deactivate</a>
{# ... #}
- <a href="#" class="btn btn-default">Activate</a>
+ <a href="{{ path('admin.affiliate.activate', {id: affiliate.id}) }}" class="btn btn-default">Activate</a>
The page should look like that:
That’s all for today, you can find the code here: https://github.com/gregurco/jobeet/tree/day12
Continue this tutorial here: Jobeet Day 13: The Mailer
Previous post is available here: Jobeet Day 11: The User
Main page is available here: Symfony 4.2 Jobeet Tutorial