From 4faae84839bd4e92a058742b314948a2d351e231 Mon Sep 17 00:00:00 2001 From: Skylar Date: Tue, 13 Feb 2024 23:01:26 -0700 Subject: [PATCH] - 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 --- config/packages/security.yaml | 5 +- config/services.yaml | 1 + src/Controller/DomainApiController.php | 21 ---- src/Controller/DomainController.php | 126 ++++++++++++++-------- src/Entity/Domain.php | 4 + src/Form/DomainType.php | 16 ++- src/Security/AccessTokenDeniedHandler.php | 2 +- 7 files changed, 105 insertions(+), 70 deletions(-) delete mode 100644 src/Controller/DomainApiController.php diff --git a/config/packages/security.yaml b/config/packages/security.yaml index f66293e..a22bcd7 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -41,6 +41,7 @@ security: pattern: ^/api/ provider: app_user_provider access_denied_handler: App\Security\AccessTokenDeniedHandler + stateless: true access_token: token_handler: App\Security\AccessTokenHandler @@ -71,8 +72,8 @@ security: # Easy way to control access for large sections of your site # Note: Only the *first* access control that matches will be used access_control: - # - { path: ^/admin, roles: ROLE_ADMIN } - # - { path: ^/profile, roles: ROLE_USER } + - { path: ^/api, roles: IS_AUTHENTICATED_FULLY } + - { path: ^/api/login, roles: PUBLIC_ACCESS } when@test: security: diff --git a/config/services.yaml b/config/services.yaml index 942c9af..f9c08c4 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -32,3 +32,4 @@ services: Symfony\Component\Uid\Command\GenerateUuidCommand: ~ Symfony\Component\Uid\Command\InspectUlidCommand: ~ Symfony\Component\Uid\Command\InspectUuidCommand: ~ + Symfony\Component\Serializer\Normalizer\FormErrorNormalizer: ~ diff --git a/src/Controller/DomainApiController.php b/src/Controller/DomainApiController.php deleted file mode 100644 index 60c8eac..0000000 --- a/src/Controller/DomainApiController.php +++ /dev/null @@ -1,21 +0,0 @@ - $domainRepository->findAll() - ]); - } -} diff --git a/src/Controller/DomainController.php b/src/Controller/DomainController.php index 74bd126..207842a 100644 --- a/src/Controller/DomainController.php +++ b/src/Controller/DomainController.php @@ -7,84 +7,124 @@ use App\Form\DomainType; use App\Repository\DomainRepository; use Doctrine\ORM\EntityManagerInterface; 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\Annotation\Route; 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; -#[Route('/domain')] +#[Route('/api')] +#[IsGranted('IS_AUTHENTICATED_FULLY')] class DomainController extends AbstractController { - #[Route('/', name: 'app_api_domain_list', methods: ['GET'])] - public function listCommentsAPI() + public function __construct( + protected SerializerInterface $serializer, + protected FormErrorNormalizer $errorNormalizer + ) { } - - #[Route('/', name: 'app_domain_index', methods: ['GET'])] - public function index(DomainRepository $domainRepository): Response + + #[Route('/domains', name: 'app_api_domain_list', methods: ['GET'])] + public function getDomains(DomainRepository $domainRepository): JsonResponse { - return $this->render('domain/index.html.twig', [ - 'domains' => $domainRepository->findAll(), + return new JsonResponse([ + '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->setOwnerToken(ByteString::fromRandom(64)->toString()); - $form = $this->createForm(DomainType::class, $domain); - $form->handleRequest($request); + $data['defaultSortPolicy'] = $data['default_sort_policy'] ?? Domain::SORT_POLICIES[array_key_first(Domain::SORT_POLICIES)]; + $form = $this->createForm(DomainType::class, $domain, [ + 'csrf_protection' => false, + 'validation_groups' => 'create' + ]); + $form->submit($data); - if ($form->isSubmitted() && $form->isValid()) { - $entityManager->persist($domain); - $entityManager->flush(); - - return $this->redirectToRoute('app_domain_index', [], Response::HTTP_SEE_OTHER); + if(!$form->isValid()) { + return new JsonResponse([ + 'errors' => $this->errorNormalizer->normalize($form) + ], Response::HTTP_BAD_REQUEST); } - return $this->render('domain/new.html.twig', [ - 'domain' => $domain, - 'form' => $form, + $entityManager->persist($domain); + $entityManager->flush(); + + 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', [ - 'domain' => $domain, + return new JsonResponse([ + 'domain' => $this->serializer->normalize($domain) ]); } - #[Route('/{id}/edit', name: 'app_domain_edit', methods: ['GET', 'POST'])] - public function edit(Request $request, Domain $domain, EntityManagerInterface $entityManager): Response + #[Route('/domains/{id}', name: 'app_api_edit_domain', methods: ['PUT'])] + public function editDomain( + Request $request, + Domain $domain, + ): Response { - $form = $this->createForm(DomainType::class, $domain); - $form->handleRequest($request); + $data = json_decode($request->getContent(), true); + 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()) { - $entityManager->flush(); - - return $this->redirectToRoute('app_domain_index', [], Response::HTTP_SEE_OTHER); + if(!$form->isValid()) { + return new JsonResponse([ + 'errors' => $this->errorNormalizer->normalize($form) + ], Response::HTTP_BAD_REQUEST); } - return $this->render('domain/edit.html.twig', [ - 'domain' => $domain, - 'form' => $form, + return new JsonResponse([ + 'domain' => $this->serializer->normalize($domain) ]); } - #[Route('/{id}', name: 'app_domain_delete', methods: ['POST'])] - public function delete(Request $request, Domain $domain, EntityManagerInterface $entityManager): Response + #[Route('/domains/{id}', name: 'app_api_delete_domain', methods: ['DELETE'])] + public function deleteDomain(Request $request, Domain $domain, EntityManagerInterface $entityManager): Response { - if ($this->isCsrfTokenValid('delete'.$domain->getId(), $request->request->get('_token'))) { - $entityManager->remove($domain); - $entityManager->flush(); - } - - return $this->redirectToRoute('app_domain_index', [], Response::HTTP_SEE_OTHER); + $entityManager->remove($domain); + $entityManager->flush(); + return new JsonResponse([]); } } diff --git a/src/Entity/Domain.php b/src/Entity/Domain.php index 4190c85..c5b9aab 100644 --- a/src/Entity/Domain.php +++ b/src/Entity/Domain.php @@ -9,8 +9,11 @@ use Doctrine\Common\Collections\Collection; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; use Gedmo\Mapping\Annotation\Timestampable; +use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; +use Symfony\Component\Serializer\Annotation\Ignore; #[ORM\Entity(repositoryClass: DomainRepository::class)] +#[UniqueEntity('domain', "This domain has already been added.")] class Domain { const SORT_POLICIES = [ @@ -49,6 +52,7 @@ class Domain private ?DateTimeInterface $createdAt = null; #[ORM\OneToMany(mappedBy: 'domain', targetEntity: DomainPage::class)] + #[Ignore] private Collection $domainPages; public function __construct() diff --git a/src/Form/DomainType.php b/src/Form/DomainType.php index 6cda80a..e452e57 100644 --- a/src/Form/DomainType.php +++ b/src/Form/DomainType.php @@ -9,27 +9,37 @@ use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Component\Validator\Constraints\Length; +use Symfony\Component\Validator\Constraints\NotBlank; class DomainType extends AbstractType { - public function buildForm(FormBuilderInterface $builder, array $options): void + public function buildForm(FormBuilderInterface $builder, array $options, $CREATE = true): void { $builder ->add('enabled', CheckboxType::class, [ - 'attr' => ['checked' => 'checked', 'value' => '1'] + 'attr' => ['checked' => 'checked', 'value' => '1'], ]) ->add('domain', TextType::class, [ 'attr' => [ 'placeholder' => 'example.com' + ], + 'constraints' => [ + new NotBlank(groups: ['create']), + new Length(min: 1, max: 255), ] ]) ->add('name', TextType::class, [ 'attr' => [ 'placeholder' => "John Doe's Blog" + ], + 'constraints' => [ + new NotBlank(groups: ['create']), + new Length(min: 1, max: 255) ] ]) ->add('defaultSortPolicy', ChoiceType::class, [ - 'choices' => Domain::SORT_POLICIES, + 'choices' => Domain::SORT_POLICIES ]) ; } diff --git a/src/Security/AccessTokenDeniedHandler.php b/src/Security/AccessTokenDeniedHandler.php index 261d07a..4714701 100644 --- a/src/Security/AccessTokenDeniedHandler.php +++ b/src/Security/AccessTokenDeniedHandler.php @@ -22,6 +22,6 @@ class AccessTokenDeniedHandler implements AccessDeniedHandlerInterface, Authenti return new JsonResponse([ 'result' => 'error', 'error' => $exception->getMessage() - ]); + ], Response::HTTP_UNAUTHORIZED); } }