#14 User model and repository
All checks were successful
Build and test. / build (push) Successful in 15s
Build and test. / test (push) Successful in 15s
Build and test. / cleanup (push) Successful in 5s

This commit is contained in:
chicory 2025-10-06 18:06:19 +03:00
parent 88f5b94c1f
commit 8e85a38f00
Signed by: chicory
GPG Key ID: AC95A793F70BEDCD
20 changed files with 401 additions and 4 deletions

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Runx\Domain\Entity;
use Runx\Domain\Types\DateTime;
use Runx\Domain\Types\Email;
use Runx\Domain\Types\Name;
use Runx\Domain\Types\Password;
use Runx\Domain\Types\Username;
class User
{
public function __construct(
public readonly Username $username,
public Email $email,
public Name $name,
public ?Password $password = null,
public ?DateTime $createdAt = null,
) {
$this->createdAt = $createdAt ?? new DateTime();
}
}

View File

@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
namespace Runx\Domain\Exception;
class DomainException extends \Exception {}

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Runx\Domain\Exception;
class EntityNotFoundException extends DomainException
{
/** @var int */
protected $code = 404;
}

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Runx\Domain\Exception;
class InvalidArgumentException extends DomainException
{
public const MESSAGE = 'Invalid value for: %s';
/** @var int */
protected $code = 400;
public function __construct(string $argName, ?\Throwable $previous = null)
{
parent::__construct(sprintf(self::MESSAGE, $argName), previous: $previous);
}
}

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Runx\Domain\Exception;
class UserNotFoundException extends EntityNotFoundException
{
private const MESSAGE = 'User "%s" not found';
public function __construct(string $uid, ?\Throwable $previous = null)
{
parent::__construct(
message: sprintf(self::MESSAGE, $uid),
previous: $previous,
);
}
}

View File

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Runx\Domain\Repository;
use Runx\Domain\Entity\User;
use Runx\Domain\Exception\UserNotFoundException;
interface UserRepositoryInterface
{
/**
* @throws UserNotFoundException
*/
public function get(string $username): User;
}

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Runx\Domain\Types;
class DateTime extends \DateTimeImmutable implements \Stringable
{
public const DATE_FORMAT = 'd.m.Y';
public function getDate(): string
{
return $this->format(self::DATE_FORMAT);
}
public function __toString(): string
{
return $this->format(\DateTimeInterface::ISO8601_EXPANDED);
}
}

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Runx\Domain\Types;
use Runx\Domain\Exception\InvalidArgumentException;
class Email implements \Stringable
{
private string $email;
public function __construct(string $email)
{
$this->email = $this->validate($email);
}
public function validate(string $email): string
{
if (false === filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('email');
}
return $this->email = $email;
}
public function __toString(): string
{
return $this->email;
}
}

34
src/Domain/Types/Name.php Normal file
View File

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Runx\Domain\Types;
use Runx\Domain\Exception\InvalidArgumentException;
class Name implements \Stringable
{
public const MIN_LENGTH = 2;
public const MAX_LENGTH = 64;
private string $name;
public function __construct(string $name)
{
$this->name = htmlspecialchars($this->validate($name), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
public function validate(string $name): string
{
if (mb_strlen($name) < self::MIN_LENGTH || mb_strlen($name) > self::MAX_LENGTH) {
throw new InvalidArgumentException('name');
}
return $this->name = $name;
}
public function __toString(): string
{
return $this->name;
}
}

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Runx\Domain\Types;
use Runx\Domain\Exception\InvalidArgumentException;
class Password implements \Stringable
{
public const MIN_LENGTH = 8;
public const MAX_LENGTH = 128;
private string $password;
public function __construct(?string $password = null)
{
if (null === $password) {
$password = uniqid();
}
$this->password = $this->validate($password);
}
public function validate(string $password): string
{
if (mb_strlen($password) < self::MIN_LENGTH || mb_strlen($password) > self::MAX_LENGTH) {
throw new InvalidArgumentException('password');
}
return $password;
}
public function __toString(): string
{
return $this->password;
}
}

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Runx\Domain\Types;
use Runx\Domain\Exception\InvalidArgumentException;
class Username implements \Stringable
{
private string $username;
public function __construct(string $username)
{
$this->username = $this->validate($username);
}
public function validate(string $username): string
{
if (!preg_match('/^[a-z_][a-z0-9_-]{1,31}$/', $username)) {
throw new InvalidArgumentException('username');
}
return $this->username = $username;
}
public function __toString(): string
{
return $this->username;
}
}

View File

@ -27,6 +27,14 @@ return function (): App {
$dependencies = require __DIR__ . '/dependencies.php';
$dependencies($containerBuilder);
/**
* Bind repositories.
*
* @var callable(ContainerBuilder<Container>):void $repositories
* */
$repositories = require __DIR__ . '/repositories.php';
$repositories($containerBuilder);
$container = $containerBuilder->build();
/** @var App<ContainerInterface> $app */

View File

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
use DI\ContainerBuilder;
use Runx\Domain\Repository\UserRepositoryInterface;
use Runx\Infrastructure\Repository\LdapUserRepository;
return function (ContainerBuilder $builder): void {
$builder->addDefinitions([
UserRepositoryInterface::class => \DI\get(LdapUserRepository::class),
]);
};

View File

@ -3,6 +3,7 @@
declare(strict_types=1);
use Runx\Infrastructure\Controller\PagesController;
use Runx\Infrastructure\Controller\UserController;
use Slim\App;
return function (App $app): void {
@ -10,4 +11,7 @@ return function (App $app): void {
$app->get('/', [PagesController::class, 'index']);
$app->get('/donate', [PagesController::class, 'donate']);
$app->get('/template', [PagesController::class, 'template']);
// User
$app->get('/user/{uid}', [UserController::class, 'read']);
};

View File

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Runx\Infrastructure\Controller;
use Psr\Http\Message\ResponseInterface;
use Runx\Domain\Repository\UserRepositoryInterface;
final class PagesController extends AbstractController
{
@ -13,8 +14,12 @@ final class PagesController extends AbstractController
return $this->render('pages/index.twig');
}
public function donate(): ResponseInterface
public function donate(UserRepositoryInterface $userRepository): ResponseInterface
{
$user = $userRepository->get('chicory');
var_dump($user);
return $this->render('pages/donate.twig');
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Runx\Infrastructure\Controller;
use Psr\Http\Message\ResponseInterface;
use Runx\Domain\Repository\UserRepositoryInterface;
use Runx\Infrastructure\Formatter\UserFormatter;
final class UserController extends AbstractController
{
public function read(
string $uid,
UserRepositoryInterface $userRepository,
UserFormatter $formatter,
): ResponseInterface {
$user = $userRepository->get($uid);
return $this->render('user/read.twig', ['user' => $formatter->format($user)]);
}
}

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Runx\Infrastructure\Formatter;
use Runx\Domain\Entity\User;
final class UserFormatter
{
/**
* @return array<string,mixed>
*/
public function format(User $user): array
{
return [
'username' => (string) $user->username,
'email' => (string) $user->email,
'name' => (string) $user->name,
'createdAt' => $user->createdAt ? $user->createdAt->getDate() : null,
];
}
}

View File

@ -8,6 +8,7 @@ use Fig\Http\Message\StatusCodeInterface;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Log\LoggerInterface;
use Runx\Domain\Exception\DomainException;
use Runx\Infrastructure\Config\ErrorHandlingConfig;
use Runx\Infrastructure\Service\RenderServiceInterface;
use Slim\Exception\HttpSpecializedException;
@ -55,21 +56,25 @@ class ErrorHandler extends SlimErrorHandler
{
return match (true) {
$this->exception instanceof HttpSpecializedException => $this->exception->getCode(),
$this->exception instanceof DomainException => $this->exception->getCode(),
default => StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR,
};
}
protected function getTitle(): string
{
return $this->exception instanceof HttpSpecializedException
? $this->exception->getTitle()
: self::DEFAULT_MESSAGE;
return match (true) {
$this->exception instanceof HttpSpecializedException => $this->exception->getTitle(),
$this->exception instanceof DomainException => $this->exception->getMessage(),
default => self::DEFAULT_MESSAGE,
};
}
protected function getMessage(): string
{
return match (true) {
$this->exception instanceof HttpSpecializedException => $this->exception->getMessage(),
$this->exception instanceof DomainException => $this->exception->getMessage(),
$this->config->displayErrorDetails => $this->exception->getMessage(),
default => self::DEFAULT_MESSAGE,
};

View File

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Runx\Infrastructure\Repository;
use Laminas\Ldap\Ldap;
use Runx\Domain\Entity\User;
use Runx\Domain\Exception\UserNotFoundException;
use Runx\Domain\Repository\UserRepositoryInterface;
use Runx\Domain\Types\DateTime;
use Runx\Domain\Types\Email;
use Runx\Domain\Types\Name;
use Runx\Domain\Types\Username;
use Runx\Infrastructure\Config\Config;
use Runx\Infrastructure\Config\LdapConfig;
class LdapUserRepository implements UserRepositoryInterface
{
private readonly LdapConfig $config;
public function __construct(
private readonly Ldap $ldap,
Config $config,
) {
$this->config = $config->ldap;
}
public function get(string $username): User
{
/** @var array<string,array<int,mixed>>|null */
$rawEntity = $this->ldap->getEntry(sprintf('uid=%s,%s', $username, $this->config->baseDn));
if (null == $rawEntity) {
throw new UserNotFoundException($username);
}
/** @var string */
$username = $rawEntity['uid'][0];
/** @var string */
$string = $rawEntity['mail'][0];
/** @var string */
$name = $rawEntity['cn'][0];
/** @var string */
$createdAt = $rawEntity['createTimestamp'][0];
return new User(
username: new Username($username),
email: new Email($string),
name: new Name($name),
password: null,
createdAt: new DateTime($createdAt),
);
}
}

14
templates/user/read.twig Normal file
View File

@ -0,0 +1,14 @@
{% extends "layouts/base.twig" %}
{% block title %}{{ user.name }}{% endblock %}
{% block content %}
<div class="page content single">
<article>
<div>
<h1>{{ user.name }}</h1><br>
<b>Username:</b> {{ user.username }}<br>
<b>Created at:</b> {{ user.createdAt }}<br>
</div>
</article>
</div>
{% endblock %}