12

Dzień 12 z 18

API dla partnerów

Opublikowany

Domykamy funkcjonalność partnerów: API zwracające aktywne oferty w JSON z uwierzytelnianiem tokenem, formularz zgłoszenia partnera i panel admina do aktywacji. Zgodnie z konwencją projektu — kontrolery w src/Api/Controller, DTO i dokumentacja OpenAPI (NelmioApiDoc).

Czego się nauczysz

  • Partnerzy w fixtures i token w lifecycle callback
  • Zapytania: aktywny partner po tokenie, oferty partnera
  • DTO oferty (fromEntity) zamiast serializacji encji
  • Kontroler API: GET /api/v1/{token}/jobs, JSON, OpenAPI
  • Formularz zgłoszenia partnera (nieaktywny do akceptacji)
  • Panel admina: aktywacja/dezaktywacja partnerów

Co zmieniło się w Symfony 8 vs 4.2

  • DTO + $this->json() zamiast JMSSerializerBundle (adnotacje na encji)
  • Zwykłe kontrolery + NelmioApiDoc zamiast FOSRestBundle / API Platform
  • Uwierzytelnianie tokenem przez #[MapEntity(expr: …)] zamiast @Entity
  • Token partnera w lifecycle callback zamiast nasłuchiwacza Doctrine

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ętego merge().


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\VirtualProperty na 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 zamiast handleView($this->view(...)) z FOSRestBundle. Token przekazujemy w nagłówku Authorization: Bearer (standard REST) zamiast w ścieżce URL — to samo podejście stosuje produkcyjny factorycode. Tam uwierzytelnianie API realizuje natywny firewall access_token Symfony 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>

Formularz zgłoszenia partnera


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>

Panel admina — lista partnerów

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.

Narzędzia / paczki

NelmioApiDoc zircote/swagger-php Symfony Serializer

Spis treści