- Domain entity unique

- Domain controller re-written to be completely API driven
- API Auth is now stateless and required on all ^/api routes
- Deleted DomainApiController
- API Auth failure code is now 401
This commit is contained in:
Skylar Sadlier 2024-02-13 23:01:26 -07:00
parent 7c45f64a73
commit 4faae84839
7 changed files with 105 additions and 70 deletions

View File

@ -41,6 +41,7 @@ security:
pattern: ^/api/ pattern: ^/api/
provider: app_user_provider provider: app_user_provider
access_denied_handler: App\Security\AccessTokenDeniedHandler access_denied_handler: App\Security\AccessTokenDeniedHandler
stateless: true
access_token: access_token:
token_handler: App\Security\AccessTokenHandler token_handler: App\Security\AccessTokenHandler
@ -71,8 +72,8 @@ security:
# Easy way to control access for large sections of your site # Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used # Note: Only the *first* access control that matches will be used
access_control: access_control:
# - { path: ^/admin, roles: ROLE_ADMIN } - { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
# - { path: ^/profile, roles: ROLE_USER } - { path: ^/api/login, roles: PUBLIC_ACCESS }
when@test: when@test:
security: security:

View File

@ -32,3 +32,4 @@ services:
Symfony\Component\Uid\Command\GenerateUuidCommand: ~ Symfony\Component\Uid\Command\GenerateUuidCommand: ~
Symfony\Component\Uid\Command\InspectUlidCommand: ~ Symfony\Component\Uid\Command\InspectUlidCommand: ~
Symfony\Component\Uid\Command\InspectUuidCommand: ~ Symfony\Component\Uid\Command\InspectUuidCommand: ~
Symfony\Component\Serializer\Normalizer\FormErrorNormalizer: ~

View File

@ -1,21 +0,0 @@
<?php
namespace App\Controller;
use App\Repository\DomainRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class DomainApiController extends AbstractController
{
#[Route('/api/domains', name: 'app_api_get_domains', methods: ['get'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function index(DomainRepository $domainRepository): JsonResponse
{
return new JsonResponse([
'domains' => $domainRepository->findAll()
]);
}
}

View File

@ -7,84 +7,124 @@ use App\Form\DomainType;
use App\Repository\DomainRepository; use App\Repository\DomainRepository;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Serializer\Exception\ExceptionInterface;
use Symfony\Component\Serializer\Normalizer\FormErrorNormalizer;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\String\ByteString; use Symfony\Component\String\ByteString;
#[Route('/domain')] #[Route('/api')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class DomainController extends AbstractController class DomainController extends AbstractController
{ {
#[Route('/', name: 'app_api_domain_list', methods: ['GET'])] public function __construct(
public function listCommentsAPI() protected SerializerInterface $serializer,
protected FormErrorNormalizer $errorNormalizer
)
{ {
} }
#[Route('/', name: 'app_domain_index', methods: ['GET'])] #[Route('/domains', name: 'app_api_domain_list', methods: ['GET'])]
public function index(DomainRepository $domainRepository): Response public function getDomains(DomainRepository $domainRepository): JsonResponse
{ {
return $this->render('domain/index.html.twig', [ return new JsonResponse([
'domains' => $domainRepository->findAll(), 'domains' => $this->serializer->normalize($domainRepository->findAll())
]); ]);
} }
#[Route('/new', name: 'app_domain_new', methods: ['GET', 'POST'])] /**
public function new(Request $request, EntityManagerInterface $entityManager): Response * @throws ExceptionInterface
* @throws \HttpInvalidParamException
*/
#[Route('/domains', name: 'app_api_add_domain', methods: ['POST'])]
public function addDomain(
DomainRepository $domainRepository,
Request $request,
EntityManagerInterface $entityManager,
): JsonResponse
{ {
$data = json_decode($request->getContent(), true);
if(json_last_error() !== JSON_ERROR_NONE) {
return new JsonResponse([
'errors' => [ 'Invalid payload' ]
], Response::HTTP_BAD_REQUEST);
}
$domain = new Domain(); $domain = new Domain();
$domain->setOwnerToken(ByteString::fromRandom(64)->toString()); $domain->setOwnerToken(ByteString::fromRandom(64)->toString());
$form = $this->createForm(DomainType::class, $domain); $data['defaultSortPolicy'] = $data['default_sort_policy'] ?? Domain::SORT_POLICIES[array_key_first(Domain::SORT_POLICIES)];
$form->handleRequest($request); $form = $this->createForm(DomainType::class, $domain, [
'csrf_protection' => false,
'validation_groups' => 'create'
]);
$form->submit($data);
if ($form->isSubmitted() && $form->isValid()) { if(!$form->isValid()) {
$entityManager->persist($domain); return new JsonResponse([
$entityManager->flush(); 'errors' => $this->errorNormalizer->normalize($form)
], Response::HTTP_BAD_REQUEST);
return $this->redirectToRoute('app_domain_index', [], Response::HTTP_SEE_OTHER);
} }
return $this->render('domain/new.html.twig', [ $entityManager->persist($domain);
'domain' => $domain, $entityManager->flush();
'form' => $form,
return new JsonResponse([
'domain' => $this->serializer->normalize($domain)
]); ]);
} }
#[Route('/{id}', name: 'app_domain_show', methods: ['GET'])] /**
public function show(Domain $domain): Response * @throws ExceptionInterface
*/
#[Route('/domains/{id}', name: 'app_api_get_domain', methods: ['GET'])]
public function getDomain(
Domain $domain
)
{ {
return $this->render('domain/show.html.twig', [ return new JsonResponse([
'domain' => $domain, 'domain' => $this->serializer->normalize($domain)
]); ]);
} }
#[Route('/{id}/edit', name: 'app_domain_edit', methods: ['GET', 'POST'])] #[Route('/domains/{id}', name: 'app_api_edit_domain', methods: ['PUT'])]
public function edit(Request $request, Domain $domain, EntityManagerInterface $entityManager): Response public function editDomain(
Request $request,
Domain $domain,
): Response
{ {
$form = $this->createForm(DomainType::class, $domain); $data = json_decode($request->getContent(), true);
$form->handleRequest($request); if(json_last_error() !== JSON_ERROR_NONE) {
return new JsonResponse([
'errors' => [ 'Invalid payload' ]
], Response::HTTP_BAD_REQUEST);
}
$form = $this->createForm(DomainType::class, $domain, [
'csrf_protection' => false,
'validation_groups' => 'update'
]);
$form->submit($data, false);
if ($form->isSubmitted() && $form->isValid()) { if(!$form->isValid()) {
$entityManager->flush(); return new JsonResponse([
'errors' => $this->errorNormalizer->normalize($form)
return $this->redirectToRoute('app_domain_index', [], Response::HTTP_SEE_OTHER); ], Response::HTTP_BAD_REQUEST);
} }
return $this->render('domain/edit.html.twig', [ return new JsonResponse([
'domain' => $domain, 'domain' => $this->serializer->normalize($domain)
'form' => $form,
]); ]);
} }
#[Route('/{id}', name: 'app_domain_delete', methods: ['POST'])] #[Route('/domains/{id}', name: 'app_api_delete_domain', methods: ['DELETE'])]
public function delete(Request $request, Domain $domain, EntityManagerInterface $entityManager): Response public function deleteDomain(Request $request, Domain $domain, EntityManagerInterface $entityManager): Response
{ {
if ($this->isCsrfTokenValid('delete'.$domain->getId(), $request->request->get('_token'))) { $entityManager->remove($domain);
$entityManager->remove($domain); $entityManager->flush();
$entityManager->flush(); return new JsonResponse([]);
}
return $this->redirectToRoute('app_domain_index', [], Response::HTTP_SEE_OTHER);
} }
} }

View File

@ -9,8 +9,11 @@ use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Gedmo\Mapping\Annotation\Timestampable; use Gedmo\Mapping\Annotation\Timestampable;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Annotation\Ignore;
#[ORM\Entity(repositoryClass: DomainRepository::class)] #[ORM\Entity(repositoryClass: DomainRepository::class)]
#[UniqueEntity('domain', "This domain has already been added.")]
class Domain class Domain
{ {
const SORT_POLICIES = [ const SORT_POLICIES = [
@ -49,6 +52,7 @@ class Domain
private ?DateTimeInterface $createdAt = null; private ?DateTimeInterface $createdAt = null;
#[ORM\OneToMany(mappedBy: 'domain', targetEntity: DomainPage::class)] #[ORM\OneToMany(mappedBy: 'domain', targetEntity: DomainPage::class)]
#[Ignore]
private Collection $domainPages; private Collection $domainPages;
public function __construct() public function __construct()

View File

@ -9,27 +9,37 @@ use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
class DomainType extends AbstractType class DomainType extends AbstractType
{ {
public function buildForm(FormBuilderInterface $builder, array $options): void public function buildForm(FormBuilderInterface $builder, array $options, $CREATE = true): void
{ {
$builder $builder
->add('enabled', CheckboxType::class, [ ->add('enabled', CheckboxType::class, [
'attr' => ['checked' => 'checked', 'value' => '1'] 'attr' => ['checked' => 'checked', 'value' => '1'],
]) ])
->add('domain', TextType::class, [ ->add('domain', TextType::class, [
'attr' => [ 'attr' => [
'placeholder' => 'example.com' 'placeholder' => 'example.com'
],
'constraints' => [
new NotBlank(groups: ['create']),
new Length(min: 1, max: 255),
] ]
]) ])
->add('name', TextType::class, [ ->add('name', TextType::class, [
'attr' => [ 'attr' => [
'placeholder' => "John Doe's Blog" 'placeholder' => "John Doe's Blog"
],
'constraints' => [
new NotBlank(groups: ['create']),
new Length(min: 1, max: 255)
] ]
]) ])
->add('defaultSortPolicy', ChoiceType::class, [ ->add('defaultSortPolicy', ChoiceType::class, [
'choices' => Domain::SORT_POLICIES, 'choices' => Domain::SORT_POLICIES
]) ])
; ;
} }

View File

@ -22,6 +22,6 @@ class AccessTokenDeniedHandler implements AccessDeniedHandlerInterface, Authenti
return new JsonResponse([ return new JsonResponse([
'result' => 'error', 'result' => 'error',
'error' => $exception->getMessage() 'error' => $exception->getMessage()
]); ], Response::HTTP_UNAUTHORIZED);
} }
} }