Dzień 12: API dla partnerów
Ostatnia z historii użytkownika — F7: partner pobiera listę aktywnych ofert. Partnerzy (affiliates) publikują oferty Jobeet na swoich stronach dzięki API. Dziś zbudujemy: API zwracające oferty w JSON z uwierzytelnianiem tokenem, formularz zgłoszenia partnera oraz panel admina do aktywacji partnerów.
Zmiana vs Symfony 4.2: oryginał składał API ręcznie z JMSSerializerBundle + FOSRestBundle (adnotacje serializacji na encji). My robimy to zgodnie z konwencją projektu: zwykłe kontrolery w
src/Api/Controller/, DTO zamiast serializacji encji i dokumentacja OpenAPI przez NelmioApiDoc (Swagger UI pod/api/doc). Encji nigdy nie serializujemy bezpośrednio.
Partnerzy — fixtures i token
Partner (Affiliate) ma token do API. Wygeneruj go automatycznie w lifecycle callbacku encji
src/Entity/Affiliate.php (analogicznie do tokenu oferty z Dnia 8):
#[ORM\PrePersist]
public function setDefaultsOnCreate(): void
{
$this->createdAt = new \DateTimeImmutable();
if (null === $this->token) {
$this->token = bin2hex(random_bytes(10));
}
}
Dodaj fixtures src/DataFixtures/AffiliateFixtures.php (tokeny wpisujemy na stałe, aby łatwo testować
API):
<?php
namespace App\DataFixtures;
use App\Entity\Affiliate;
use App\Entity\Category;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;
class AffiliateFixtures extends Fixture implements DependentFixtureInterface
{
public function load(ObjectManager $manager): void
{
$sensio = (new Affiliate())
->setUrl('https://sensiolabs.com/')
->setEmail('contact@sensiolabs.com')
->setActive(true)
->setToken('sensio_labs')
->addCategory($this->getReference(CategoryFixtures::PROGRAMMING, Category::class));
$knp = (new Affiliate())
->setUrl('https://knplabs.com/')
->setEmail('hello@knplabs.com')
->setActive(true)
->setToken('knp_labs')
->addCategory($this->getReference(CategoryFixtures::PROGRAMMING, Category::class))
->addCategory($this->getReference(CategoryFixtures::DESIGN, Category::class));
$manager->persist($sensio);
$manager->persist($knp);
$manager->flush();
}
public function getDependencies(): array
{
return [CategoryFixtures::class];
}
}
docker compose exec app php bin/console doctrine:fixtures:load --no-interaction
Zmiana vs Symfony 4.2: token generujemy w lifecycle callbacku (nie osobnym nasłuchiwaczu Doctrine), referencje pobieramy przez
getReference(ref, Category::class)bez usuniętegomerge().
Zapytania repozytoriów
API zwraca oferty tylko z kategorii przypisanych partnerowi i tylko dla aktywnych partnerów.
Najpierw wyszukiwanie aktywnego partnera po tokenie — src/Repository/AffiliateRepository.php:
public function findActiveByToken(string $token): ?Affiliate
{
return $this->createQueryBuilder('a')
->andWhere('a.token = :token')
->andWhere('a.active = :active')
->setParameter('token', $token)
->setParameter('active', true)
->getQuery()
->getOneOrNullResult();
}
Teraz oferty dla partnera — src/Repository/JobRepository.php (JOIN przez kategorie partnera):
use App\Entity\Affiliate;
/** @return Job[] */
public function findActiveJobsForAffiliate(Affiliate $affiliate): array
{
return $this->createQueryBuilder('j')
->innerJoin('j.category', 'c')
->innerJoin('c.affiliates', 'a')
->andWhere('a = :affiliate')
->andWhere('j.expiresAt > :now')
->andWhere('j.activated = :activated')
->setParameter('affiliate', $affiliate)
->setParameter('now', new \DateTimeImmutable())
->setParameter('activated', true)
->orderBy('j.expiresAt', 'DESC')
->getQuery()
->getResult();
}
DTO oferty
Encji nie serializujemy wprost — mapujemy je na DTO, które kontroluje kształt odpowiedzi i dostarcza
dokumentację OpenAPI. Utwórz src/Api/Dto/JobDto.php:
<?php
namespace App\Api\Dto;
use App\Entity\Job;
use OpenApi\Attributes as OA;
#[OA\Schema]
class JobDto
{
public function __construct(
#[OA\Property(example: 1)]
public readonly int $id,
#[OA\Property(example: 'full-time')]
public readonly string $type,
#[OA\Property(example: 'Sensio Labs')]
public readonly string $company,
#[OA\Property(example: 'https://sensiolabs.com/', nullable: true)]
public readonly ?string $url,
#[OA\Property(example: 'Web Developer')]
public readonly string $position,
#[OA\Property(example: 'Paris, France')]
public readonly string $location,
#[OA\Property]
public readonly string $description,
#[OA\Property]
public readonly string $howToApply,
#[OA\Property(example: 'Programming')]
public readonly string $category,
#[OA\Property(example: 'uploads/jobs/logo.png', nullable: true)]
public readonly ?string $logoPath,
#[OA\Property(example: '2025-07-01T12:00:00+00:00')]
public readonly string $expiresAt,
) {}
public static function fromEntity(Job $job): self
{
return new self(
id: $job->getId(),
type: $job->getType(),
company: $job->getCompany(),
url: $job->getUrl(),
position: $job->getPosition(),
location: $job->getLocation(),
description: $job->getDescription(),
howToApply: $job->getHowToApply(),
category: $job->getCategory()->getName(),
logoPath: $job->getLogo() ? 'uploads/jobs/' . $job->getLogo() : null,
expiresAt: $job->getExpiresAt()->format(\DateTimeInterface::ATOM),
);
}
}
Zmiana vs Symfony 4.2: zamiast adnotacji
@JMS\Expose/@JMS\Type/@JMS\VirtualPropertyna encji, definiujemy osobny DTO z metodąfromEntity(). Encja pozostaje czysta, a odpowiedź API jest jawnie zdefiniowana i udokumentowana atrybutami#[OA\Property].
Kontroler API
Kontrolery API trzymamy w src/Api/Controller/. Utwórz src/Api/Controller/JobApiController.php.
Token partnera przekazujemy w nagłówku Authorization: Bearer <token> (standard REST), a odpowiedź
budujemy z DTO metodą $this->json():
<?php
namespace App\Api\Controller;
use App\Api\Dto\JobDto;
use App\Repository\AffiliateRepository;
use App\Repository\JobRepository;
use Nelmio\ApiDocBundle\Attribute\Model;
use OpenApi\Attributes as OA;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api/v1', name: 'api_v1_')]
#[OA\Tag(name: 'Jobs')]
class JobApiController extends AbstractController
{
#[Route('/jobs', name: 'jobs', methods: ['GET'])]
#[OA\Get(
path: '/api/v1/jobs',
summary: 'Aktywne oferty dla partnera',
description: 'Zwraca aktywne oferty z kategorii przypisanych partnerowi. Wymaga nagłówka Authorization: Bearer <token>.',
parameters: [
new OA\Parameter(
name: 'Authorization',
in: 'header',
required: true,
description: 'Token partnera w formacie "Bearer <token>"',
schema: new OA\Schema(type: 'string', example: 'Bearer sensio_labs'),
),
],
responses: [
new OA\Response(
response: 200,
description: 'Lista aktywnych ofert',
content: new OA\JsonContent(
type: 'array',
items: new OA\Items(ref: new Model(type: JobDto::class)),
),
),
new OA\Response(response: 401, description: 'Brak, nieprawidłowy lub nieaktywny token'),
],
)]
public function jobs(Request $request, AffiliateRepository $affiliates, JobRepository $jobs): JsonResponse
{
$token = $this->bearerToken($request);
$affiliate = null !== $token ? $affiliates->findActiveByToken($token) : null;
if (null === $affiliate) {
return $this->json(['error' => 'Nieprawidłowy lub nieaktywny token.'], Response::HTTP_UNAUTHORIZED);
}
return $this->json(array_map(JobDto::fromEntity(...), $jobs->findActiveJobsForAffiliate($affiliate)));
}
private function bearerToken(Request $request): ?string
{
$header = $request->headers->get('Authorization', '');
return str_starts_with($header, 'Bearer ') ? substr($header, 7) : null;
}
}
Wywołaj endpoint z tokenem w nagłówku — zobaczysz JSON z ofertami:
curl -H "Authorization: Bearer sensio_labs" http://localhost:8090/api/v1/jobs
[
{
"id": 1,
"type": "full-time",
"company": "Sensio Labs",
"url": "https://sensiolabs.com/",
"position": "Web Developer",
"location": "Paris, France",
"description": "...",
"howToApply": "...",
"category": "Programming",
"logoPath": "uploads/jobs/sensio-labs.gif",
"expiresAt": "2025-08-01T12:00:00+00:00"
}
]
Brak nagłówka, błędny lub nieaktywny token zwróci 401. Endpoint jest też widoczny w Swagger UI pod http://localhost:8090/api/doc.
Zmiana vs Symfony 4.2:
$this->json()z tablicą DTO zamiasthandleView($this->view(...))z FOSRestBundle. Token przekazujemy w nagłówkuAuthorization: Bearer(standard REST) zamiast w ścieżce URL — to samo podejście stosuje produkcyjny factorycode. Tam uwierzytelnianie API realizuje natywny firewallaccess_tokenSymfony z rate limitingiem (pełny opis:docs/api-authentication.md).
Formularz zgłoszenia partnera
Aby zostać partnerem, użytkownik wypełnia formularz (URL, e-mail, kategorie). Utwórz src/Form/AffiliateType.php:
<?php
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\Count;
use Symfony\Component\Validator\Constraints\Email;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
class AffiliateType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('url', UrlType::class, [
'label' => 'Adres strony',
'required' => false,
'default_protocol' => 'https',
'constraints' => [new Length(max: 255)],
])
->add('email', EmailType::class, [
'label' => 'E-mail',
'constraints' => [new NotBlank(), new Email()],
])
->add('categories', EntityType::class, [
'label' => 'Kategorie',
'class' => Category::class,
'choice_label' => 'name',
'multiple' => true,
'expanded' => true,
'constraints' => [new Count(min: 1, minMessage: 'Wybierz co najmniej jedną kategorię.')],
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults(['data_class' => Affiliate::class]);
}
}
Kontroler src/Controller/AffiliateController.php — po zapisie partner jest nieaktywny (czeka na
akceptację admina):
<?php
namespace App\Controller;
use App\Entity\Affiliate;
use App\Form\AffiliateType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/affiliate', name: 'affiliate_')]
final class AffiliateController extends AbstractController
{
#[Route('/register', name: 'register', methods: ['GET', 'POST'])]
public function register(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); // aktywuje admin
$em->persist($affiliate);
$em->flush();
return $this->redirectToRoute('affiliate_wait');
}
return $this->render('affiliate/register.html.twig', ['form' => $form]);
}
#[Route('/wait', name: 'wait', methods: ['GET'])]
public function wait(): Response
{
return $this->render('affiliate/wait.html.twig');
}
}
Szablon templates/affiliate/register.html.twig:
{% extends 'base.html.twig' %}
{% block title %}Zostań partnerem — Jobeet{% endblock %}
{% block body %}
<h1 class="h3 mb-4">Zostań partnerem</h1>
{{ form_start(form) }}
{{ form_widget(form) }}
<button type="submit" class="btn btn-primary mt-3">Wyślij zgłoszenie</button>
{{ form_end(form) }}
{% endblock %}
Szablon templates/affiliate/wait.html.twig:
{% extends 'base.html.twig' %}
{% block body %}
<div class="alert alert-success mt-4">
<h2 class="h4">Zgłoszenie przyjęte</h2>
<p class="mb-0">Dziękujemy! Po aktywacji konta przez administratora otrzymasz e-mailem swój token do API.</p>
</div>
{% endblock %}
Dodaj link „Zostań partnerem” w templates/base.html.twig (przed przyciskiem „Dodaj ofertę”):
<a href="{{ path('affiliate_register') }}" class="btn btn-outline-light">Zostań partnerem</a>

Panel admina — zarządzanie partnerami
Zgłoszenia partnerów aktywuje admin. Utwórz src/Controller/Admin/AffiliateController.php (w stylu CRUD
z Dnia 10, zabezpieczony rolą z Dnia 11):
<?php
namespace App\Controller\Admin;
use App\Entity\Affiliate;
use App\Repository\AffiliateRepository;
use Doctrine\ORM\EntityManagerInterface;
use Pagerfanta\Doctrine\ORM\QueryAdapter;
use Pagerfanta\Exception\OutOfRangeCurrentPageException;
use Pagerfanta\Pagerfanta;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/admin/affiliates', name: 'admin_affiliate_')]
#[IsGranted('ROLE_ADMIN')]
final class AffiliateController extends AbstractController
{
private const int PER_PAGE = 10;
#[Route('', name: 'index', methods: ['GET'])]
public function index(Request $request, AffiliateRepository $affiliates): Response
{
// niezaakceptowani (active=false) na górze listy
$qb = $affiliates->createQueryBuilder('a')
->orderBy('a.active', 'ASC')
->addOrderBy('a.createdAt', 'DESC');
$pager = new Pagerfanta(new QueryAdapter($qb));
$pager->setMaxPerPage(self::PER_PAGE);
try {
$pager->setCurrentPage($request->query->getInt('page', 1));
} catch (OutOfRangeCurrentPageException) {
throw new NotFoundHttpException();
}
return $this->render('admin/affiliate/index.html.twig', ['pager' => $pager]);
}
#[Route('/{id}/activate', name: 'activate', methods: ['POST'], requirements: ['id' => '\d+'])]
public function activate(Request $request, Affiliate $affiliate, EntityManagerInterface $em): Response
{
if ($this->isCsrfTokenValid('toggle-affiliate-' . $affiliate->getId(), (string) $request->request->get('_token'))) {
$affiliate->setActive(true);
$em->flush();
$this->addFlash('success', 'Partner został aktywowany.');
}
return $this->redirectToRoute('admin_affiliate_index');
}
#[Route('/{id}/deactivate', name: 'deactivate', methods: ['POST'], requirements: ['id' => '\d+'])]
public function deactivate(Request $request, Affiliate $affiliate, EntityManagerInterface $em): Response
{
if ($this->isCsrfTokenValid('toggle-affiliate-' . $affiliate->getId(), (string) $request->request->get('_token'))) {
$affiliate->setActive(false);
$em->flush();
$this->addFlash('success', 'Partner został dezaktywowany.');
}
return $this->redirectToRoute('admin_affiliate_index');
}
}
Szablon templates/admin/affiliate/index.html.twig:
{% extends 'admin/base.html.twig' %}
{% block body %}
<h1 class="h3 mb-3">Partnerzy</h1>
<table class="table table-hover align-middle">
<thead>
<tr>
<th>E-mail</th>
<th>URL</th>
<th class="text-center">Aktywny</th>
<th class="text-end">Akcje</th>
</tr>
</thead>
<tbody>
{% for affiliate in pager %}
<tr>
<td>{{ affiliate.email }}</td>
<td><a href="{{ affiliate.url }}" target="_blank" rel="noopener">{{ affiliate.url }}</a></td>
<td class="text-center">
{% if affiliate.active %}
<span class="badge bg-success">Tak</span>
{% else %}
<span class="badge bg-danger">Nie</span>
{% endif %}
</td>
<td class="text-end">
{% set action = affiliate.active ? 'deactivate' : 'activate' %}
<form method="post" action="{{ path('admin_affiliate_' ~ action, { id: affiliate.id }) }}" class="d-inline">
<input type="hidden" name="_token" value="{{ csrf_token('toggle-affiliate-' ~ affiliate.id) }}">
<button class="btn btn-sm {{ affiliate.active ? 'btn-outline-danger' : 'btn-outline-success' }}">
{{ affiliate.active ? 'Dezaktywuj' : 'Aktywuj' }}
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if pager.haveToPaginate %}
<nav class="d-flex justify-content-center">
<ul class="pagination">
{% for p in range(1, pager.nbPages) %}
<li class="page-item {{ p == pager.currentPage ? 'active' }}">
<a class="page-link" href="{{ path('admin_affiliate_index', { page: p }) }}">{{ p }}</a>
</li>
{% endfor %}
</ul>
</nav>
{% endif %}
{% endblock %}
Dodaj pozycję „Partnerzy” do menu w templates/admin/base.html.twig:
<a class="list-group-item list-group-item-action {{ route starts with 'admin_affiliate_' ? 'active' }}"
href="{{ path('admin_affiliate_index') }}">Partnerzy</a>

Aktywacja przez POST z tokenem CSRF (zmiana stanu to nie GET) — spójnie z usuwaniem z Dnia 10.
Podsumowanie
Zamknęliśmy funkcjonalność partnerów i API:
- ✅ API ofert w JSON (
/api/v1/jobs, token w nagłówku Bearer) z filtrowaniem po kategoriach partnera, - ✅ DTO (
JobDto::fromEntity) zamiast serializacji encji + dokumentacja OpenAPI (Swagger/api/doc), - ✅ token partnera generowany w lifecycle callbacku,
- ✅ formularz zgłoszenia partnera (nieaktywny do akceptacji) + strona oczekiwania,
- ✅ panel admina: lista partnerów, aktywacja/dezaktywacja przez POST + CSRF.
W Dniu 13 zajmiemy się wysyłką e-maili (Symfony Mailer + Mailpit) — np. powiadomieniem partnera z tokenem po aktywacji konta.