Skip to content

Commit

Permalink
feat: project crud (#12)
Browse files Browse the repository at this point in the history
* feat: add exceptions

* ForbiddenException
* NotFoundException
* ProjectManagerException

* feat(helper): add Randomizer

* feat(project): add slug

* add SluggableTrait
* update entity
* add migration

* chore(Makefile): add db drop-create-migrate-fixtures targets

* feat: add Project DTO,Mapper

* feat(helper): add ApiMessages,ApiResponse

* feat: add ProjectController

* feat: add project validator

* fix(ProjectDTO): set nullable slug

A nullable slug is required when creating a new project

* feat(ApiMessages): add project related translations

* feat: add Project helper

* feat(validator): add "not empty" methods

* feat: add Finder

* fix(SluggableTrait): getSlug return type

* feat(BadDataException): add default message, status code

* refac(DTO): set default values

* refac(Helper): set validateRequestResource Project argument not null

* fix(Validator): translate validateKnownEntity exception message

* refac(Controller): use MapRequestPayload for create/update function

* feat: add ExceptionLogger

* feat: add poc template

* feat: add Archiver

* feat: add persister

* feat: add Handler

* refac: improve Finder::get,Handler::HandleGetProject

* refac(ApiMessages): add missing messages and sort

* fix(Helper): generateEditSucccessMessage typo

* fix(Helper): edit/create check

* feat: do not edit/show archived data

close: #12
  • Loading branch information
n3wborn authored Sep 11, 2023
1 parent 37c2821 commit f4dcf17
Show file tree
Hide file tree
Showing 22 changed files with 781 additions and 1 deletion.
27 changes: 26 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ SYMFONY = $(PHP_CONT) bin/console

# Misc
.DEFAULT_GOAL = help
.PHONY : help build up start down logs sh composer vendor sf cc
.PHONY : help build up start down logs sh composer vendor sf cc db-fixtures db-create db-migration db-reset db-restore db-drop db-save

## —— 🎵 🐳 The Symfony Docker Makefile 🐳 🎵 ——————————————————————————————————
help: ## Outputs this help screen
Expand All @@ -27,6 +27,9 @@ build-pull-cache: ## Pull images, don't use cache and build the Docker image
up: ## Start the docker hub
@$(DOCKER_COMP) up --force-recreate --remove-orphans

up-renew-anon-volumes: ## Start the docker hub
@$(DOCKER_COMP) up --force-recreate --remove-orphans --renew-anon-volumes

stop: ## Stop containers
@$(DOCKER_COMP) stop

Expand Down Expand Up @@ -57,3 +60,25 @@ sf: ## List all Symfony commands or pass the parameter "c=" to run a given comma

cc: c=c:c ## Clear the cache
cc: sf

## —— Database ———————————————————————————————————————————————————————————————
db-create:
@$(SYMFONY) doctrine:database:create --if-not-exists || true

db-migration:
@$(SYMFONY) doctrine:migrations:migrate -n

db-drop:
@$(SYMFONY) doctrine:database:drop --force || true

db-save:
docker exec -it project-manager-database mysqldump -uroot -proot db_name | gzip > db.sql.gz

db-restore:
gunzip -c db.sql.gz | docker exec -i project-manager-database -uroot -proot db_name

db-reset: db-drop db-create db-migration db-fixtures

db-fixtures:
@$(SYMFONY) doctrine:fixtures:load -n --env=dev

27 changes: 27 additions & 0 deletions migrations/Version20230906163121.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20230906163121 extends AbstractMigration
{
public function getDescription(): string
{
return 'add Project slug column';
}

public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE project ADD slug VARCHAR(255) NOT NULL');
}

public function down(Schema $schema): void
{
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE project DROP slug');
}
}
54 changes: 54 additions & 0 deletions src/Controller/ProjectController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

namespace App\Controller;

use App\Entity\Project;
use App\Service\Project\DTO;
use App\Service\Project\Handler;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Annotation\Route;

final class ProjectController extends AbstractController
{
public function __construct(
private Handler $handler,
) {
}

public const ROUTE_ADD = 'project_add';
public const ROUTE_ARCHIVE = 'project_archive';
public const ROUTE_EDIT = 'project_edit';
public const ROUTE_FETCH = 'project_fetch';
public const ROUTE_FIND_ALL = 'project_find_all';

#[Route('/project/{slug}', name: self::ROUTE_FETCH, methods: Request::METHOD_GET)]
public function getProject(?Project $project): JsonResponse
{
return $this->handler->handleGetProject($project);
}

#[Route('/project', name: self::ROUTE_ADD, methods: Request::METHOD_POST)]
#[Route('/project/{slug}', name: self::ROUTE_EDIT, methods: Request::METHOD_POST)]
public function persistProject(
?Project $project,
#[MapRequestPayload()] ?DTO $dto,
Request $request,
): JsonResponse {
return $this->handler->handlePersistProject($project, $request, $dto);
}

#[Route('/projects', name: self::ROUTE_FIND_ALL, methods: Request::METHOD_GET)]
public function getAllProjects(): JsonResponse
{
return $this->handler->handleGetAllProjects();
}

#[Route('/project/{slug}', name: self::ROUTE_ARCHIVE, methods: 'DELETE')]
public function classeArchive(?Project $project): JsonResponse
{
return $this->handler->handleArchiveProject($project);
}
}
1 change: 1 addition & 0 deletions src/Entity/Project.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
class Project
{
use ArchivableEntity;
use SluggableTrait;

#[ORM\Id]
#[ORM\Column(type: UuidType::NAME, unique: true)]
Expand Down
31 changes: 31 additions & 0 deletions src/Entity/SluggableTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

namespace App\Entity;

use App\Helper\Randomizer;
use Doctrine\ORM\Mapping as ORM;

trait SluggableTrait
{
#[ORM\Column(type: 'string', length: 255)]
private ?string $slug = null;

final public function getSlug(): ?string
{
return $this->slug;
}

final public function setSlug(string $slug): self
{
$this->slug = $slug;

return $this;
}

#[ORM\PrePersist]
final public function generateSlug(): void
{
null === $this->slug
&& $this->setSlug(Randomizer::generateSlug());
}
}
13 changes: 13 additions & 0 deletions src/Exception/BadDataException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

namespace App\Exception;

use Symfony\Component\HttpFoundation\Response;

class BadDataException extends ProjectManagerException
{
public function __construct(string $message = '')
{
parent::__construct($message, Response::HTTP_BAD_REQUEST);
}
}
8 changes: 8 additions & 0 deletions src/Exception/ForbiddenException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?php

namespace App\Exception;

class ForbiddenException extends ProjectManagerException
{
public const ACCESS_DENIED_EXCEPTION_MESSAGE = "Vous n'êtes pas autorisé à accéder à cette page";
}
7 changes: 7 additions & 0 deletions src/Exception/NotFoundException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php

namespace App\Exception;

class NotFoundException extends ProjectManagerException
{
}
7 changes: 7 additions & 0 deletions src/Exception/ProjectManagerException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php

namespace App\Exception;

class ProjectManagerException extends \Exception
{
}
47 changes: 47 additions & 0 deletions src/Helper/ApiMessages.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

namespace App\Helper;

final class ApiMessages
{
public const ACCESS_DENIED_EXCEPTION_MESSAGE = "Vous n'êtes pas autorisé à accéder à cette page";
public const DEFAULT_ERROR_MESSAGE = 'Oops, an error occured...';
public const DEFAULT_NOT_FOUND_MESSAGE = "La ressource demandée n'a pas été trouvée";
public const DEFAULT_UNSUPPORTED_FORMAT_MESSAGE = 'La sérialisation a échouée';
public const DUPLICATED_RESOURCE_MESSAGE = 'Les données de la ressource ne peuvent être enregistrées (génererait un doublon)';
public const EMAIL_ADDRESS_UNKNOWN = "Aucun utilisateur avec cette adresse mail n'a été trouvé";
public const ERROR_EMPTY_PASSWORD_TERMS = 'The presented password cannot be empty.';
public const ERROR_MAIL_TERMS = 'Bad credentials.';
public const ERROR_PASSWORD_TERMS = 'The presented password is invalid.';
public const FETCHING_USER_PROFILE_ERROR_MESSAGE = 'An error occurred while fetching user profile';
public const INDEX_ERROR = 'error';
public const INDEX_MESSAGE = 'message';
public const INDEX_STATUS = 'status';
public const INDEX_SUCCESS = 'success';
public const INDEX_WARNING = 'warning';
public const MESSAGE_OK = 'OK';
public const PROJECT_CREATE_ERROR_MESSAGE = 'An error occurred while persisting project';
public const PROJECT_CREATE_SUCCESS_MESSAGE = 'Le project a bien été créé';
public const PROJECT_DELETE_ERROR_MESSAGE = 'An error occurred during project removal';
public const PROJECT_DELETE_SUCCESS_MESSAGE = 'Le projet a bien été supprimé';
public const PROJECT_NAME_UNAVAILABLE = 'Project name already exists';
public const PROJECT_NOT_FOUND = 'Project not found';
public const PROJECT_UNKNOWN = 'Project not found';
public const PROJECT_UPDATE_ERROR_MESSAGE = 'An error occurred during project update';
public const PROJECT_UPDATE_SUCCESS_MESSAGE = 'Le projet a bien été mis à jour';

public static function translate(string $key): string
{
return self::TRANSLATIONS[$key] ?? $key;
}

public const TRANSLATIONS = [
self::ERROR_EMPTY_PASSWORD_TERMS => 'Le mot de passe est nécessaire',
self::ERROR_MAIL_TERMS => 'Votre mot de passe ou votre adresse mail est incorrect',
self::ERROR_PASSWORD_TERMS => 'Votre mot de passe ou votre adresse mail est incorrect',
self::FETCHING_USER_PROFILE_ERROR_MESSAGE => 'Oops, une erreur est survenue durant la récupération de vos données',
self::PROJECT_NOT_FOUND => "Le projet n'a pas été trouvé",
self::PROJECT_UNKNOWN => "Le projet n'a pas été trouvé",
self::PROJECT_UPDATE_ERROR_MESSAGE => 'Oops, une erreur est survenue lors de la modification du projet',
];
}
68 changes: 68 additions & 0 deletions src/Helper/ApiResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php

namespace App\Helper;

use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;

class ApiResponse extends JsonResponse
{
public static function createAndFormat(
mixed $data,
?string $message = ApiMessages::MESSAGE_OK,
?string $key = ApiMessages::INDEX_SUCCESS,
?int $statusCode = Response::HTTP_OK
): self {
return self::create([
'data' => $data,
'status' => $key,
'message' => $message,
], $statusCode);
}

public static function create($data, $statusCode = self::HTTP_OK): self
{
return new self($data, $statusCode);
}

public static function createMessage($message, $statusCode = self::HTTP_OK): self
{
return new self(
[
ApiMessages::INDEX_STATUS => ApiMessages::INDEX_SUCCESS,
ApiMessages::INDEX_MESSAGE => $message,
],
$statusCode);
}

public static function createWarningMessage($message, $statusCode = self::HTTP_BAD_REQUEST): self
{
return new self(
[
ApiMessages::INDEX_STATUS => ApiMessages::INDEX_WARNING,
ApiMessages::INDEX_MESSAGE => $message,
],
$statusCode
);
}

public static function createErrorMessage(
string $message,
int $statusCode = self::HTTP_INTERNAL_SERVER_ERROR,
\Throwable $exception = null
): self {
$data = [
ApiMessages::INDEX_STATUS => ApiMessages::INDEX_ERROR,
ApiMessages::INDEX_MESSAGE => $message,
];

if ('dev' === $_ENV['APP_ENV']) {
$data['debug'] = $exception?->getMessage();
}

return new self(
$data,
$statusCode
);
}
}
39 changes: 39 additions & 0 deletions src/Helper/ExceptionLogger.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

namespace App\Helper;

use Psr\Log\LoggerInterface;

final class ExceptionLogger
{
public function __construct(
private LoggerInterface $logger,
) {
}

public function logAndTrace(\Throwable $exception, string $message = null): void
{
$message && $this->logger->error($message);
$this->logger->error($exception->getMessage());
$this->logger->debug($exception->getTraceAsString());
}

public function logCriticalAndTrace(\Throwable $exception, string $message = null): void
{
$message && $this->logger->critical($message);
$this->logger->critical($exception->getMessage());
$this->logger->critical($exception->getTraceAsString());
}

public function log(\Throwable $exception, string $message = null): void
{
$message && $this->logger->error($message);
$this->logger->error($exception->getMessage());
}

public function logNotice(\Throwable $exception, string $message = null): void
{
$message && $this->logger->notice($message);
$this->logger->notice($exception->getMessage());
}
}
33 changes: 33 additions & 0 deletions src/Helper/Randomizer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

namespace App\Helper;

use Psr\Log\LoggerInterface;

final class Randomizer
{
public function __construct(
private LoggerInterface $logger,
) {
}

public static function generateSlug(): string
{
return self::generateRandomBytes();
}

public static function generateRandomBytes(int $nbytes = 16): string
{
try {
$result = bin2hex(random_bytes($nbytes));

version_compare(phpversion(), '8.2.0', '>=')
&& $result = bin2hex((new \Random\Randomizer())->getBytes($nbytes));
} catch (\Throwable $throwable) {
$this->logger->error($throwable->getMessage());
$result = uniqid(uniqid('', true), true);
}

return $result;
}
}
Loading

0 comments on commit f4dcf17

Please sign in to comment.