-
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