diff --git a/config/dependencies.php b/config/dependencies.php index 67e5055..99f6564 100644 --- a/config/dependencies.php +++ b/config/dependencies.php @@ -2,6 +2,12 @@ declare(strict_types=1); +use App\Adapter\Repository\Database\DbLongUrlRepository; +use App\Domain\Repository\LongUrlRepository; +use App\Shared\Adapter\Contracts\DatabaseOrm; +use App\Shared\Adapter\Contracts\UuidGenerator; +use App\Shared\Infra\PdoOrm; +use App\Shared\Infra\RamseyUiidAdapter; use DI\ContainerBuilder; use Odan\Session\Middleware\SessionMiddleware; use Odan\Session\PhpSession; @@ -24,5 +30,18 @@ $storage = []; return new Messages($storage); }, + DatabaseOrm::class => function (ContainerInterface $c) { + return new PdoOrm($c->get('db')); + }, + UuidGenerator::class => function (ContainerInterface $c) { + return new RamseyUiidAdapter(); + }, + LongUrlRepository::class => function (ContainerInterface $c) { + return new DbLongUrlRepository( + $c->get(DatabaseOrm::class), + $c->get(RamseyUiidAdapter::class), + $c->get('config') + ); + }, ]); }; diff --git a/config/middleware.php b/config/middleware.php index b690f54..41fdda0 100644 --- a/config/middleware.php +++ b/config/middleware.php @@ -3,7 +3,7 @@ declare(strict_types=1); use Slim\App; -use App\Middleware\SessionMiddleware; +use App\Adapter\Middleware\SessionMiddleware; use Slim\Views\TwigMiddleware; return function (App $app) { diff --git a/config/routes.php b/config/routes.php index b2607d5..6eb4a5f 100644 --- a/config/routes.php +++ b/config/routes.php @@ -2,9 +2,9 @@ declare(strict_types=1); -use App\Controllers\AccessUrlController; -use App\Controllers\HomeController; -use App\Controllers\ShortenUrlController; +use App\Adapter\Controllers\AccessUrlController; +use App\Adapter\Controllers\HomeController; +use App\Adapter\Controllers\ShortenUrlController; use Slim\App; use Slim\Routing\RouteCollectorProxy; diff --git a/public_html/js/script.js b/public_html/js/script.js index 489e7dd..de4fdb6 100644 --- a/public_html/js/script.js +++ b/public_html/js/script.js @@ -3,6 +3,19 @@ $(document).ready(function () { if (urlHistory) { urlHistory = JSON.parse(urlHistory); + + urlHistory = urlHistory.map(row => { + if (!row.hasOwnProperty('huge')) return row; + return { + longUrl: row.huge, + shortenedUrl: row.shortened, + createdAt: row.created_at, + economyRate: row.economyRate, + }; + }); + + window.localStorage.setItem('url_history', JSON.stringify(urlHistory)); + for (let index in urlHistory) { if (index === '5') break; $('div.shortened-urls ul').append(getHistoryItemTemplate(urlHistory[index])); @@ -58,7 +71,7 @@ $(document).ready(function () { $.ajax({ url: `${baseUrl}/api/public/shorten`, type: 'post', - data: { huge_url : $inputUrl.val() }, + data: { long_url : $inputUrl.val() }, beforeSend : function() { $inputUrl.prop('disabled', true); $btnShorten.prop('disabled', true); @@ -85,15 +98,15 @@ $(document).ready(function () { const $divResult = $('div.shortened-url-result'); $divResult.css('display', 'flex'); - $divResult.find('a').attr('href', payload.data.shortened); - $divResult.find('a span.url-text').html(payload.data.shortened); + $divResult.find('a').attr('href', payload.data.shortenedUrl); + $divResult.find('a span.url-text').html(payload.data.shortenedUrl); $divResult.find('a span.badge').html(`-${payload.data.economyRate}%`); - $divResult.find('button').attr('data-url', payload.data.shortened); + $divResult.find('button').attr('data-url', payload.data.shortenedUrl); }).fail(function(jqXHR, textStatus, msg) { if (jqXHR.status === 400) { const payload = jqXHR.responseJSON; let message = ''; - switch (payload.data.huge_url) { + switch (payload.data.longUrl) { case 'invalid-url': message = 'Insira ua URL vĂ¡lida com "http://" ou "https://" para poder encurtar.'; break; @@ -132,16 +145,16 @@ $(document).ready(function () { function getHistoryItemTemplate(data) { return `
  • -
    ${data.huge.substring(0, 38)}...
    +
    ${data.longUrl.substring(0, 38)}...
    -
    diff --git a/src/Adapter/Controllers/AccessUrlController.php b/src/Adapter/Controllers/AccessUrlController.php new file mode 100644 index 0000000..d10482f --- /dev/null +++ b/src/Adapter/Controllers/AccessUrlController.php @@ -0,0 +1,48 @@ +view = $container->get('view'); + $this->longUrlRepo = $container->get(LongUrlRepository::class); + } + + public function __invoke(Request $request, Response $response, array $args): Response + { + $url = $this->longUrlRepo->getUrlByPath($args['path']); + + if (is_null($url)) { + return $this->view->render($response, 'notfound.html.twig', []); + } + + $this->longUrlRepo->registerAccess($url, [ + 'REMOTE_ADDR' => $_SERVER['REMOTE_ADDR'], + 'REMOTE_PORT' => $_SERVER['REMOTE_PORT'], + 'SERVER_NAME' => $_SERVER['SERVER_NAME'], + 'REQUEST_URI' => $_SERVER['REQUEST_URI'], + 'HTTP_HOST' => $_SERVER['HTTP_HOST'], + 'HTTP_USER_AGENT' => $_SERVER['HTTP_USER_AGENT'] + ]); + + $newResponse = $response + ->withHeader('Content-type', 'application/json') + ->withStatus(302) + ->withHeader('Location', $url->longUrl->value()); + + return $newResponse; + } +} \ No newline at end of file diff --git a/src/Controllers/HomeController.php b/src/Adapter/Controllers/HomeController.php similarity index 52% rename from src/Controllers/HomeController.php rename to src/Adapter/Controllers/HomeController.php index eb0d737..e22a894 100644 --- a/src/Controllers/HomeController.php +++ b/src/Adapter/Controllers/HomeController.php @@ -2,9 +2,10 @@ declare(strict_types=1); -namespace App\Controllers; +namespace App\Adapter\Controllers; use PDO; +use App\Domain\Repository\LongUrlRepository; use Psr\Container\ContainerInterface; use Psr\Http\Message\RequestInterface as Request; use Psr\Http\Message\ResponseInterface as Response; @@ -12,30 +13,18 @@ final class HomeController { - private PDO $db; private Twig $view; + private LongUrlRepository $longUrlRepo; public function __construct(ContainerInterface $container) { - $this->db = $container->get('db'); $this->view = $container->get('view'); + $this->longUrlRepo = $container->get(LongUrlRepository::class); } public function __invoke(Request $request, Response $response, array $args): Response { - $stmt = $this->db->prepare(trim(" - select count(*) as total_urls from urls - union all - select count(*) as total_clicks from urls_logs - ")); - - $stmt->execute(); - - [$totalUrls, $totalClicks] = $stmt->fetchAll(PDO::FETCH_COLUMN); - - return $this->view->render($response, 'index.html.twig', [ - 'totalUrls' => ceil($totalUrls), - 'totalClicks' => ceil($totalClicks) - ]); + $rows = $this->longUrlRepo->countUrlsAndClicks(); + return $this->view->render($response, 'index.html.twig', $rows); } } \ No newline at end of file diff --git a/src/Adapter/Controllers/ShortenUrlController.php b/src/Adapter/Controllers/ShortenUrlController.php new file mode 100644 index 0000000..07406f5 --- /dev/null +++ b/src/Adapter/Controllers/ShortenUrlController.php @@ -0,0 +1,72 @@ +config = $container->get('config'); + $this->useCase = $container->get(ShortenUrl::class); + } + + public function __invoke(Request $request, Response $response, array $args): Response + { + $contents = $request->getParsedBody(); + + if (empty($contents) || !array_key_exists('long_url', $contents)) { + $newResponse = $response + ->withHeader('Content-type', 'application/json') + ->withStatus(400); + + $newResponse->getBody()->write(json_encode([ + 'status' => 'fail', + 'data' => ['long_url' => 'missing-param'] + ])); + + return $newResponse; + } + + if (empty($contents['long_url'])) { + $newResponse = $response + ->withHeader('Content-type', 'application/json') + ->withStatus(400); + + $newResponse->getBody()->write(json_encode([ + 'status' => 'fail', + 'data' => ['long_url' => 'empty-value'] + ])); + + return $newResponse; + } + + $result = $this->useCase->execute(InputData::create([ + 'longUrl' => $contents['long_url'], + 'type' => LongUrlType::TYPE_RANDOM, + 'baseUrl' => $this->config['baseUrl'], + ])); + + $newResponse = $response + ->withHeader('Content-type', 'application/json') + ->withStatus(200); + + $newResponse->getBody()->write(json_encode([ + 'status' => 'success', + 'data' => $result->values() + ])); + + return $newResponse; + } +} \ No newline at end of file diff --git a/src/Middleware/SessionMiddleware.php b/src/Adapter/Middleware/SessionMiddleware.php similarity index 95% rename from src/Middleware/SessionMiddleware.php rename to src/Adapter/Middleware/SessionMiddleware.php index 632de19..f6827e9 100644 --- a/src/Middleware/SessionMiddleware.php +++ b/src/Adapter/Middleware/SessionMiddleware.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Middleware; +namespace App\Adapter\Middleware; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; diff --git a/src/Middleware/SlimFlashMiddleware.php b/src/Adapter/Middleware/SlimFlashMiddleware.php similarity index 95% rename from src/Middleware/SlimFlashMiddleware.php rename to src/Adapter/Middleware/SlimFlashMiddleware.php index 8343f7c..87f2c82 100644 --- a/src/Middleware/SlimFlashMiddleware.php +++ b/src/Adapter/Middleware/SlimFlashMiddleware.php @@ -1,6 +1,6 @@ orm->read('urls', ['short_url_path' => $url->getShortUrlPath()], [ + 'columns' => ['id'] + ]); + + if (!is_null($urlRecord)) { + $url->renewShortUrlPath(); + continue; + } + + break; + } + + $url->set('id', $this->uuidGenerator->create()); + + $this->orm->create('urls', [ + 'id' => $this->uuidGenerator->toBytes($url->id), + 'uuid' => $url->id, + 'long_url' => $url->longUrl->value(), + 'short_url_path' => $url->getShortUrlPath(), + 'type' => $url->type->value(), + 'economy_rate' => $url->calculateEconomyRate(), + 'created_at' => $url->createdAt->format('Y-m-d H:i:s') + ]); + + return $url; + } + + public function getUrlByPath(string $path):? LongUrl + { + $urlRecord = $this->orm->read('urls', ['short_url_path' => $path]); + + if (is_null($urlRecord)) { + return null; + } + + return LongUrl::create([ + 'id' => $urlRecord['uuid'], + 'longUrl' => $urlRecord['long_url'], + 'shortUrl' => $this->config['baseUrl'] . '/' . $urlRecord['short_url_path'], + 'baseUrlToShortUrl' => $this->config['baseUrl'], + 'type' => $urlRecord['type'], + 'createdAt' => $urlRecord['created_at'] + ]); + } + + public function registerAccess(LongUrl $url, array $metaInfo = []) + { + $uuid = $this->uuidGenerator->create(); + + $this->orm->create('urls_logs', [ + 'id' => $this->uuidGenerator->toBytes($uuid), + 'uuid' => $uuid, + 'url_id' => $this->uuidGenerator->toBytes($url->id), + 'created_at' => (new DateTimeImmutable())->format('Y-m-d H:i:s'), + 'meta' => json_encode($metaInfo) + ]); + } + + public function countUrlsAndClicks(): array + { + $sql = "select count(*) as `total_urls` from `urls` union all select count(*) as `total_clicks` from `urls_logs`"; + $rows = $this->orm->querySql($sql, [], ['fetchMode' => PDO::FETCH_COLUMN]); + + return [ + 'totalUrls' => ceil($rows[0]), + 'totalClicks' => ceil($rows[1]) + ]; + } +} \ No newline at end of file diff --git a/src/Controllers/AccessUrlController.php b/src/Controllers/AccessUrlController.php deleted file mode 100644 index 94054e1..0000000 --- a/src/Controllers/AccessUrlController.php +++ /dev/null @@ -1,63 +0,0 @@ -db = $container->get('db'); - $this->view = $container->get('view'); - } - - public function __invoke(Request $request, Response $response, array $args): Response - { - $stmt = $this->db->prepare("SELECT `id`, `long_url` FROM `urls` WHERE `short_url_path` = :path"); - $stmt->execute(['path' => $args['path']]); - $row = $stmt->fetch(); - - if (!$row) { - return $this->view->render($response, 'notfound.html.twig', []); - } - - $sql = "INSERT INTO `urls_logs` (`id`, `uuid`, `url_id`, `created_at`, `meta`) VALUES (:id, :uuid, :url_id, :created_at, :meta)"; - $stmt = $this->db->prepare($sql); - - $uuid = Uuid::uuid4(); - - $stmt->execute([ - 'id' => $uuid->getBytes(), - 'uuid' => $uuid->toString(), - 'url_id' => $row['id'], - 'created_at' => (new DateTimeImmutable())->format('Y-m-d H:i:s'), - 'meta' => json_encode([ - 'REMOTE_ADDR' => $_SERVER['REMOTE_ADDR'], - 'REMOTE_PORT' => $_SERVER['REMOTE_PORT'], - 'SERVER_NAME' => $_SERVER['SERVER_NAME'], - 'REQUEST_URI' => $_SERVER['REQUEST_URI'], - 'HTTP_HOST' => $_SERVER['HTTP_HOST'], - 'HTTP_USER_AGENT' => $_SERVER['HTTP_USER_AGENT'] - ]), - ]); - - $newResponse = $response - ->withHeader('Content-type', 'application/json') - ->withStatus(302) - ->withHeader('Location', $row['long_url']); - - return $newResponse; - } -} \ No newline at end of file diff --git a/src/Controllers/ShortenUrlController.php b/src/Controllers/ShortenUrlController.php deleted file mode 100644 index f7d27bb..0000000 --- a/src/Controllers/ShortenUrlController.php +++ /dev/null @@ -1,119 +0,0 @@ -db = $container->get('db'); - $this->config = $container->get('config'); - } - - public function __invoke(Request $request, Response $response, array $args): Response - { - $contents = $request->getParsedBody(); - - if (empty($contents) || !array_key_exists('huge_url', $contents)) { - $newResponse = $response - ->withHeader('Content-type', 'application/json') - ->withStatus(400); - - $newResponse->getBody()->write(json_encode([ - 'status' => 'fail', - 'data' => ['huge_url' => 'missing-param'] - ])); - - return $newResponse; - } - - if (empty($contents['huge_url'])) { - $newResponse = $response - ->withHeader('Content-type', 'application/json') - ->withStatus(400); - - $newResponse->getBody()->write(json_encode([ - 'status' => 'fail', - 'data' => ['huge_url' => 'empty-value'] - ])); - - return $newResponse; - } - - if (filter_var($contents['huge_url'], FILTER_VALIDATE_URL) === false) { - $newResponse = $response - ->withHeader('Content-type', 'application/json') - ->withStatus(400); - - $newResponse->getBody()->write(json_encode([ - 'status' => 'fail', - 'data' => ['huge_url' => 'invalid-url'] - ])); - - return $newResponse; - } - - $stmt = $this->db->prepare(trim(" - INSERT INTO `urls` (`id`, `uuid`, `long_url`, `short_url_path`, `economy_rate`, `created_at`) - VALUES (:id, :uuid, :long_url, :short_url_path, :economy_rate, :created_at) - ")); - - $shortUrlPath = substr(sha1(uniqid((string)rand(), true)), 0, 5); - - while (true) { - $stmt2 = $this->db->prepare('select `id` from `urls` where `short_url_path` = :path'); - $stmt2->execute(['path' => $shortUrlPath]); - $row = $stmt2->fetch(); - - if ($row !== false) { - $shortUrlPath = substr(sha1(uniqid((string)rand(), true)), 0, 5); - continue; - } - - break; - } - - $fullShortenUrl = $this->config['baseUrl'] . '/' . $shortUrlPath; - $uuid = Uuid::uuid4(); - $createdAt = new DateTimeImmutable(); - $economyRate = ceil(100 - ((strlen($fullShortenUrl) * 100) / strlen($contents['huge_url']))); - - $stmt->execute([ - 'id' => $uuid->getBytes(), - 'uuid' => $uuid->toString(), - 'long_url' => $contents['huge_url'], - 'short_url_path' => $shortUrlPath, - 'economy_rate' => $economyRate, - 'created_at' => $createdAt->format('Y-m-d H:i:s') - ]); - - $newResponse = $response - ->withHeader('Content-type', 'application/json') - ->withStatus(200); - - $newResponse->getBody()->write(json_encode([ - 'status' => 'success', - 'data' => [ - 'huge' => $contents['huge_url'], - 'shortened' => $fullShortenUrl, - 'created_at' => $createdAt->format(DateTimeInterface::ATOM), - 'economyRate' => $economyRate - ] - ])); - - return $newResponse; - } -} \ No newline at end of file diff --git a/src/Domain/Entity/LongUrl.php b/src/Domain/Entity/LongUrl.php new file mode 100644 index 0000000..9568605 --- /dev/null +++ b/src/Domain/Entity/LongUrl.php @@ -0,0 +1,86 @@ +format(DateTimeInterface::ATOM); + } + + if (!isset($values['shortUrl'])) { + $values['shortUrl'] = $values['baseUrlToShortUrl'] . '/' . self::generatePathToShortUrl(); + } + + return parent::create($values); + } + + protected function setBaseUrlToShortUrl(string $baseUrl) + { + $this->baseUrlToShortUrl = new Url($baseUrl); + } + + protected function setLongUrl(string $longUrl) + { + $this->longUrl = new Url($longUrl); + } + + protected function setShortUrl(string $shortUrl) + { + $this->shortUrl = new Url($shortUrl); + } + + protected function setType(string $type) + { + $this->type = new LongUrlType($type); + } + + protected function setCreatedAt(string $createdAt) + { + $this->createdAt = new DateTimeImmutable($createdAt); + } + + public function calculateEconomyRate(): float + { + return ceil(100 - ((strlen($this->shortUrl->value()) * 100) / strlen($this->longUrl->value()))); + } + + public function getShortUrlPath(): string + { + $urlParts = explode('/', $this->shortUrl->value()); + return end($urlParts); + } + + public function renewShortUrlPath(): void + { + $this->shortUrl = new Url($this->baseUrlToShortUrl . '/' . self::generatePathToShortUrl()); + } + + public static function generatePathToShortUrl(): string + { + return substr(sha1(uniqid((string)rand(), true)), 0, 5); + } +} diff --git a/src/Domain/Exceptions/InvalidLongUrl.php b/src/Domain/Exceptions/InvalidLongUrl.php new file mode 100644 index 0000000..5375dd9 --- /dev/null +++ b/src/Domain/Exceptions/InvalidLongUrl.php @@ -0,0 +1,32 @@ +message = $message; + $this->details = $details; + parent::__construct($this->message, $code, $previous); + } + + public static function forInvalidType(string $giveType, array $details = []): self + { + return new self("The given URL Type '{$giveType}' is invalid", $details); + } + + public function details(): array + { + return $this->details(); + } +} \ No newline at end of file diff --git a/src/Domain/Repository/LongUrlRepository.php b/src/Domain/Repository/LongUrlRepository.php new file mode 100644 index 0000000..396f0ad --- /dev/null +++ b/src/Domain/Repository/LongUrlRepository.php @@ -0,0 +1,15 @@ + $input->longUrl, + 'baseUrlToShortUrl' => $input->baseUrl, + 'type' => $input->type + ]); + + $longUrl = $this->urlRepo->shortLongUrl($longUrl); + + return OutputData::create([ + 'longUrl' => $longUrl->longUrl->value(), + 'shortenedUrl' => $longUrl->shortUrl->value(), + 'economyRate' => $longUrl->calculateEconomyRate(), + 'createdAt' => $longUrl->createdAt->format(DateTimeInterface::ATOM), + ]); + } +} \ No newline at end of file diff --git a/src/Domain/ValueObject/LongUrlType.php b/src/Domain/ValueObject/LongUrlType.php new file mode 100644 index 0000000..2fc6c9d --- /dev/null +++ b/src/Domain/ValueObject/LongUrlType.php @@ -0,0 +1,33 @@ +type = $type; + } + + public function value(): mixed + { + return $this->type; + } +} \ No newline at end of file diff --git a/src/Shared/Adapter/Contracts/DatabaseOrm.php b/src/Shared/Adapter/Contracts/DatabaseOrm.php new file mode 100644 index 0000000..547db6d --- /dev/null +++ b/src/Shared/Adapter/Contracts/DatabaseOrm.php @@ -0,0 +1,16 @@ + + */ +interface Dto +{ + /** + * Associative array such as `'property' => 'value'` with all boundary values + * @return array + */ + public function values(): array; + + /** + * Get a boundary value by property + * @param string $property + * @return mixed + */ + public function get(string $property); +} diff --git a/src/Shared/Adapter/Contracts/UuidGenerator.php b/src/Shared/Adapter/Contracts/UuidGenerator.php new file mode 100644 index 0000000..367ea89 --- /dev/null +++ b/src/Shared/Adapter/Contracts/UuidGenerator.php @@ -0,0 +1,11 @@ + + */ +abstract class DtoBase implements Dto +{ + private array $values = []; + + /** + * Boundary constructor. + * @param array $values + * @throws InvalidDtoParam + */ + final private function __construct(array $values) + { + foreach ($values as $key => $value) { + if (mb_strstr($key, '_') !== false) { + $key = lcfirst(str_replace('_', '', ucwords($key, '_'))); + } + + if (!property_exists($this, $key)) { + throw InvalidDtoParam::forDynamicParam(get_class(), $key); + } + + $this->{$key} = $value; + $this->values[$key] = $this->get($key); + } + } + + /** + * Static method to create a Boundary (Input or Output) + * @param array $values Associative array such as `'property' => 'value'` + * @throws InvalidDtoParam + */ + public static function create(array $values): static + { + return new static($values); + } + + /** + * {@inheritdoc} + */ + public function values(): array + { + return $this->values; + } + + /** + * {@inheritdoc} + * @throws InvalidDtoParam + */ + public function get(string $property) + { + return $this->__get($property); + } + + /** + * Magic getter method to get a Boundary property value + * @param string $name + * @return mixed + * @throws InvalidDtoParam + */ + public function __get(string $name) + { + $getter = "get" . ucfirst($name); + + if (method_exists($this, $getter)) { + return $this->{$getter}(); + } + + if (!property_exists($this, $name)) { + throw InvalidDtoParam::forDynamicParam(get_class(), $name); + } + + return $this->{$name}; + } + + /** + * @param string $name + * @param mixed $value + * @throws InvalidDtoParam + */ + public function __set(string $name, mixed $value) + { + throw InvalidDtoParam::forReadonlyProperty(get_class(), $name, $value); + } + + public function __isset($name): bool + { + return property_exists($this, $name); + } +} diff --git a/src/Shared/Adapter/Expcetion/InvalidDtoParam.php b/src/Shared/Adapter/Expcetion/InvalidDtoParam.php new file mode 100644 index 0000000..41839eb --- /dev/null +++ b/src/Shared/Adapter/Expcetion/InvalidDtoParam.php @@ -0,0 +1,44 @@ +details = $details; + } + + public static function forDynamicParam(string $className, string $property): self + { + return new self(sprintf( + "It couldn't construct DTO '%s' because the property '%s' doesn't exist", + $className, $property + ), [ + 'className' => $className, + 'property' => $property, + ]); + } + + public static function forReadonlyProperty(string $className, string $property, $value): self + { + return new self(sprintf( + "You cannot change the property '%s' of the DTO class '%s' because it is read-only.", + $property, $className + ), [ + 'className' => $className, + 'property' => $property, + 'value' => $value + ]); + } +} \ No newline at end of file diff --git a/src/Shared/Domain/Contracts/DomainException.php b/src/Shared/Domain/Contracts/DomainException.php new file mode 100644 index 0000000..b86bf01 --- /dev/null +++ b/src/Shared/Domain/Contracts/DomainException.php @@ -0,0 +1,10 @@ + 'value'` + * @return void + */ + public function fill(array $values): void; + + /** + * Method that contains the property setter logic + * @param string $property Object property name + * @param mixed $value Value to be inserted in property + * @return Entity + */ + public function set(string $property, $value): Entity; + + /** + * Method that contains the property getter logic + * @param string $property Object property name + * @return mixed + */ + public function get(string $property); + + /** + * Output an array based on entity properties + * @param bool $toSnakeCase + * @return array + */ + public function toArray(bool $toSnakeCase = false): array; +} diff --git a/src/Shared/Domain/Contracts/ValueObject.php b/src/Shared/Domain/Contracts/ValueObject.php new file mode 100644 index 0000000..c947ce9 --- /dev/null +++ b/src/Shared/Domain/Contracts/ValueObject.php @@ -0,0 +1,33 @@ + + * + * @property-read int|string $id + */ +abstract class EntityBase implements Entity +{ + protected string | int | null $id = null; + + /** + * Entity constructor. + * @param array $values + * @throws EntityException + */ + final private function __construct(array $values) + { + $this->fill($values); + } + + /** + * Static method to create an Entity + * @param array $values + * @return EntityBase + * @throws EntityException + */ + public static function create(array $values): self + { + return new static($values); + } + + /** + * @inheritDoc + */ + public function getId() + { + return $this->id; + } + + /** + * @inheritDoc + * @throws EntityException + */ + public function fill(array $values): void + { + foreach ($values as $attribute => $value) { + $this->set($attribute, $value); + } + } + + /** + * @inheritDoc + * @throws EntityException + */ + public function set(string $property, $value): Entity + { + if (mb_strstr($property, '_') !== false) { + $property = lcfirst(str_replace('_', '', ucwords($property, '_'))); + } + + $setter = 'set' . str_replace('_', '', ucwords($property, '_')); + + if (method_exists($this, $setter)) { + $this->{$setter}($value); + return $this; + } + + if (!property_exists($this, $property)) { + $className = get_class(); + throw EntityException::readonlyProperty($className, $property, [ + 'className' => $className, + 'property' => $property, + 'value' => $value + ]); + } + + $this->{$property} = $value; + return $this; + } + + /** + * @inheritDoc + */ + public function get(string $property) + { + return $this->{$property}; + } + + /** + * @inheritDoc + * @throws ReflectionException + */ + public function toArray(bool $toSnakeCase = false): array + { + $props = []; + $propertyList = get_object_vars($this); + + /** @var int|string|object $value */ + foreach ($propertyList as $prop => $value) { + if ($value instanceof DateTimeInterface) { + $propertyList[$prop] = $value->format(DATE_ATOM); + continue; + } + + if (is_object($value)) { + $reflectObject = new ReflectionClass(get_class($value)); + $properties = $reflectObject->getProperties(); + $propertyList[$prop] = []; + + foreach ($properties as $property) { + $property->setAccessible(true); + $propertyList[$prop][$property->getName()] = $property->getValue($value); + } + } + } + + $propertyList = json_decode(json_encode($propertyList), true); + + foreach ($propertyList as $name => $value) { + if ($toSnakeCase) { + $name = mb_strtolower(preg_replace('/(?{$getter}(); + } + + if (!property_exists($this, $name)) { + $className = get_class(); + throw EntityException::propertyDoesNotExists($className, $name, [ + 'className' => $className, + 'propertyName' => $name + ]); + } + + return $this->{$name}; + } + + /** + * @param mixed $value + * @throws EntityException + */ + public function __set(string $name, $value) + { + $className = get_class(); + throw EntityException::readonlyProperty($className, $name, [ + 'className' => $className, + 'property' => $name, + 'value' => $value + ]); + } +} diff --git a/src/Shared/Domain/Exceptions/EntityException.php b/src/Shared/Domain/Exceptions/EntityException.php new file mode 100644 index 0000000..cc821ed --- /dev/null +++ b/src/Shared/Domain/Exceptions/EntityException.php @@ -0,0 +1,46 @@ +message = $message; + $this->details = $details; + parent::__construct($this->message, $code, $previous); + } + + public static function readonlyProperty(string $className, string $propertyName, array $details = []): self + { + return new self( + "You cannot change the property '{$propertyName}' of the Entity class '{$className}' because it is read-only.", + $details + ); + } + + public static function propertyDoesNotExists(string $className, string $propertyName, array $details = []): self + { + return new self( + "You cannot get the property '{$propertyName}' because it doesn't exist in Entity '{$className}'", + $details + ); + } + + public function details(): array + { + return $this->details; + } +} diff --git a/src/Shared/Domain/Exceptions/InvalidUrlException.php b/src/Shared/Domain/Exceptions/InvalidUrlException.php new file mode 100644 index 0000000..1a3621e --- /dev/null +++ b/src/Shared/Domain/Exceptions/InvalidUrlException.php @@ -0,0 +1,38 @@ +message = $message; + $this->details = $details; + parent::__construct($this->message, $code, $previous); + } + + public static function forEmptyUrl(): self + { + return new self("URL cannot be empty."); + } + + public static function forInvalidUrl(string $giveUrl, array $details = []): self + { + return new self("The given URL '{$giveUrl}' is invalid", $details); + } + + public function details(): array + { + return $this->details(); + } +} \ No newline at end of file diff --git a/src/Shared/Domain/ValueObjectBase.php b/src/Shared/Domain/ValueObjectBase.php new file mode 100644 index 0000000..0238347 --- /dev/null +++ b/src/Shared/Domain/ValueObjectBase.php @@ -0,0 +1,46 @@ +value(); + } + + /** + * @inheritDoc + */ + public function isEqualsTo(ValueObject $valueObject): bool + { + return $this->objectHash() === $valueObject->objectHash(); + } + + /** + * @inheritDoc + * @throws ReflectionException + */ + public function objectHash(): string + { + $reflectObject = new ReflectionClass(get_class($this)); + $props = $reflectObject->getProperties(); + $value = ''; + + foreach ($props as $prop) { + $prop->setAccessible(true); + $value .= $prop->getValue($this); + } + + return md5($value); + } +} diff --git a/src/Shared/Domain/ValueObjects/Url.php b/src/Shared/Domain/ValueObjects/Url.php new file mode 100644 index 0000000..dc5fd54 --- /dev/null +++ b/src/Shared/Domain/ValueObjects/Url.php @@ -0,0 +1,34 @@ + $url]); + } + + $this->url = $url; + } + + public function value(): mixed + { + return $this->url; + } +} diff --git a/src/Shared/Infra/PdoOrm.php b/src/Shared/Infra/PdoOrm.php new file mode 100644 index 0000000..3df7fe6 --- /dev/null +++ b/src/Shared/Infra/PdoOrm.php @@ -0,0 +1,97 @@ + ':' . $columnName, $columns); + $columns = array_map(fn($columnName) => '`' . $columnName . '`', $columns); + $sql = sprintf( + 'INSERT INTO `%s` (%s) VALUES (%s)', + $tableName, + implode(',', $columns), + implode(',', $columnsVars) + ); + $stmt = $this->pdo->prepare($sql); + $stmt->execute($values); + + return $this->pdo->lastInsertId('uuid'); + } + + public function read(string $tableName, array $filters, array $options = []): ?array + { + $columns = '*'; + $clauses = ''; + + if (isset($options['columns'])) { + $columns = array_map(fn ($columnName) => '`' . $columnName . '`', $options['columns']); + $columns = implode(',', $columns); + } + + $filterColumns = array_keys($filters); + $i = 0; + + foreach ($filterColumns as $columnName) { + if ($i === 0) { + $clauses .= "WHERE `{$columnName}` = :{$columnName}"; + } + + if ($i > 0) { + $clauses .= "AND `{$columnName}` = :{$columnName}"; + } + + $i++; + } + + $sql = sprintf("SELECT %s FROM `%s` %s", $columns, $tableName, $clauses); + $stmt = $this->pdo->prepare($sql); + $stmt->execute($filters); + $row = $stmt->fetch(); + + if (!$row) { + return null; + } + + return $row; + } + + public function update(string $tableName, array $values, array $conditions): bool + { + return true; + } + + public function delete(string $tableName, array $conditions): bool + { + return true; + } + + public function search(string $tableName, array $filters, array $options = []): array + { + return []; + } + + public function persist(string $tableName, array $values): int|string + { + return ''; + } + + public function querySql(string $sql, array $values = [], array $options = []): array + { + $stmt = $this->pdo->prepare(trim($sql)); + $stmt->execute($values); + $fetchMode = $options['fetchMode'] ?? PDO::FETCH_ASSOC; + return $stmt->fetchAll($fetchMode); + } +} \ No newline at end of file diff --git a/src/Shared/Infra/RamseyUiidAdapter.php b/src/Shared/Infra/RamseyUiidAdapter.php new file mode 100644 index 0000000..b29f6cc --- /dev/null +++ b/src/Shared/Infra/RamseyUiidAdapter.php @@ -0,0 +1,21 @@ +toString(); + } + + public function toBytes(string $uuid): string + { + return Uuid::fromString($uuid)->getBytes(); + } +} \ No newline at end of file