feat: architecture

Signed-off-by: Superkooka <aymeric.gueracague@gmail.com>
This commit is contained in:
Aymeric GUERACAGUE 2025-12-20 16:30:06 +01:00
parent 2b7144614f
commit aa12eb92a4
Signed by: Superkooka
GPG Key ID: F78F2B172E894865
22 changed files with 79428 additions and 8 deletions

View File

@ -25,3 +25,5 @@ APP_SHARE_DIR=var/share
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
DEFAULT_URI=http://localhost
###< symfony/routing ###
SPORT_RADAR_APIKEY=

View File

@ -15,6 +15,10 @@ build:
install:
docker-compose -f docker-compose.yaml run --rm nhl-schedule composer install
.PHONY: run
run:
docker-compose -f docker-compose.yaml run --rm nhl-schedule php bin/console app:get-nhl-schedule
.PHONY: static-check
static-check:
docker-compose -f docker-compose.yaml run --rm nhl-schedule php -d memory_limit=4G vendor/bin/phpstan analyse -c phpstan.neon

View File

@ -11,6 +11,7 @@
"symfony/dotenv": "8.0.*",
"symfony/flex": "^2",
"symfony/framework-bundle": "8.0.*",
"symfony/http-client": "8.0.*",
"symfony/runtime": "8.0.*",
"symfony/yaml": "8.0.*"
},

176
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "1319f86cd294d1bc92527e45febb8246",
"content-hash": "43beda2ab15c41986add9f99a79923ae",
"packages": [
{
"name": "psr/cache",
@ -1367,6 +1367,180 @@
],
"time": "2025-12-06T16:55:34+00:00"
},
{
"name": "symfony/http-client",
"version": "v8.0.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client.git",
"reference": "727fda60d0aebfdfcc4c8bc4661f0cb8f44153c0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-client/zipball/727fda60d0aebfdfcc4c8bc4661f0cb8f44153c0",
"reference": "727fda60d0aebfdfcc4c8bc4661f0cb8f44153c0",
"shasum": ""
},
"require": {
"php": ">=8.4",
"psr/log": "^1|^2|^3",
"symfony/http-client-contracts": "~3.4.4|^3.5.2",
"symfony/service-contracts": "^2.5|^3"
},
"conflict": {
"amphp/amp": "<3",
"php-http/discovery": "<1.15"
},
"provide": {
"php-http/async-client-implementation": "*",
"php-http/client-implementation": "*",
"psr/http-client-implementation": "1.0",
"symfony/http-client-implementation": "3.0"
},
"require-dev": {
"amphp/http-client": "^5.3.2",
"amphp/http-tunnel": "^2.0",
"guzzlehttp/promises": "^1.4|^2.0",
"nyholm/psr7": "^1.0",
"php-http/httplug": "^1.0|^2.0",
"psr/http-client": "^1.0",
"symfony/cache": "^7.4|^8.0",
"symfony/dependency-injection": "^7.4|^8.0",
"symfony/http-kernel": "^7.4|^8.0",
"symfony/messenger": "^7.4|^8.0",
"symfony/process": "^7.4|^8.0",
"symfony/rate-limiter": "^7.4|^8.0",
"symfony/stopwatch": "^7.4|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\HttpClient\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously",
"homepage": "https://symfony.com",
"keywords": [
"http"
],
"support": {
"source": "https://github.com/symfony/http-client/tree/v8.0.1"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-12-05T14:08:45+00:00"
},
{
"name": "symfony/http-client-contracts",
"version": "v3.6.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client-contracts.git",
"reference": "75d7043853a42837e68111812f4d964b01e5101c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c",
"reference": "75d7043853a42837e68111812f4d964b01e5101c",
"shasum": ""
},
"require": {
"php": ">=8.1"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/contracts",
"name": "symfony/contracts"
},
"branch-alias": {
"dev-main": "3.6-dev"
}
},
"autoload": {
"psr-4": {
"Symfony\\Contracts\\HttpClient\\": ""
},
"exclude-from-classmap": [
"/Test/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Generic abstractions related to HTTP clients",
"homepage": "https://symfony.com",
"keywords": [
"abstractions",
"contracts",
"decoupling",
"interfaces",
"interoperability",
"standards"
],
"support": {
"source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-04-29T11:18:49+00:00"
},
{
"name": "symfony/http-foundation",
"version": "v8.0.1",

View File

@ -463,7 +463,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator;
* },
* disallow_search_engine_index?: bool, // Enabled by default when debug is enabled. // Default: true
* http_client?: bool|array{ // HTTP Client configuration
* enabled?: bool, // Default: false
* enabled?: bool, // Default: true
* max_host_connections?: int, // The maximum number of connections to a single host.
* default_options?: array{
* headers?: array<string, mixed>,

View File

@ -7,17 +7,23 @@
# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:
app.sport_radar_apikey: '%env(SPORT_RADAR_APIKEY)%'
services:
# default configuration for services in *this* file
_defaults:
autowire: true # Automatically injects dependencies in your services.
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
# makes classes in src/ available to be used as services
# this creates a service per class whose id is the fully-qualified class name
App\:
resource: '../src/'
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones
App\Application\CommandBus\UseCaseCommandBus:
arguments:
$handlers: !tagged_iterator 'app.usecase'
App\Application\UseCase\:
resource: '../src/Application/UseCase/'
tags: ['app.usecase']
exclude:
- '../src/Application/UseCase/*Request.php'
App\Application\SportRadar\SportRadarEngine: '@App\Infrastructure\SportRadar\HTTPSportRadarEngine'

78919
debug.json Normal file

File diff suppressed because it is too large Load Diff

View File

@ -2,3 +2,8 @@ parameters:
level: 8
paths:
- src
ignoreErrors:
-
message: "#^Property .+::.+ is never read, only written\\.$#"
paths:
- src/Domain/Entity/*.php

View File

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Application\CommandBus;
class UseCaseCommandBus
{
/** @var array<class-string, callable> */
private array $handlers = [];
/**
* @param iterable<object> $handlers
*/
public function __construct(iterable $handlers)
{
foreach ($handlers as $handler) {
$this->handlers[$this->resolveQueryClass($handler)] = $handler;
}
}
public function ask(object $query): mixed
{
$queryClass = $query::class;
if (!isset($this->handlers[$queryClass])) {
throw new \RuntimeException(sprintf('No query handler found for %s', $queryClass));
}
return ($this->handlers[$queryClass])($query);
}
private function resolveQueryClass(object $handler): string
{
$reflection = new \ReflectionMethod($handler, '__invoke');
$param = $reflection->getParameters()[0];
$type = $param->getType();
if (!$type instanceof \ReflectionNamedType) {
throw new \RuntimeException('Handler parameter must have a single named type');
}
return $type->getName();
}
}

View File

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Application\SportRadar;
interface SportRadarEngine
{
public function getNHLSchedule(): void;
}

View File

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Application\UseCase;
class GetNHLMatch
{
public function __invoke(GetNHLMatchRequest $request): void
{
dd($request->type);
}
}

View File

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Application\UseCase;
class GetNHLMatchRequest
{
public function __construct(
public readonly string $type,
) {
}
}

View File

@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Domain\Entity;
abstract class AEntity
{
protected string $id;
public function __construct()
{
$this->id = $this->generateUUIDv4();
}
/**
* @author Grégoire Pineau <lyrixx@lyrixx.info>
*
* https://github.com/symfony/polyfill-uuid/blob/a41886c1c81dc075a09c71fe6db5b9d68c79de23/Uuid.php#L378-L438
*/
private function generateUUIDv4(): string
{
$timeOffsetInt = 0x01B21DD213814000;
$time = microtime(false);
$time = substr($time, 11) . substr($time, 2, 7);
$time = str_pad(dechex((int) $time + $timeOffsetInt), 16, '0', \STR_PAD_LEFT);
// https://tools.ietf.org/html/rfc4122#section-4.1.5
// We are using a random data for the sake of simplicity: since we are
// not able to get a super precise timeOfDay as a unique sequence
$clockSeq = random_int(0, 0x3FFF);
static $node;
if (null === $node) {
if (\function_exists('apcu_fetch')) {
$node = apcu_fetch('__symfony_uuid_node');
if (false === $node) {
$node = sprintf(
'%06x%06x',
random_int(0, 0xFFFFFF) | 0x010000,
random_int(0, 0xFFFFFF)
);
apcu_store('__symfony_uuid_node', $node);
}
} else {
$node = sprintf(
'%06x%06x',
random_int(0, 0xFFFFFF) | 0x010000,
random_int(0, 0xFFFFFF)
);
}
}
return sprintf(
'%08s-%04s-1%03s-%04x-%012s',
// 32 bits for "time_low"
substr($time, -8),
// 16 bits for "time_mid"
substr($time, -12, 4),
// 16 bits for "time_hi_and_version",
// four most significant bits holds version number 1
substr($time, -15, 3),
// 16 bits:
// * 8 bits for "clk_seq_hi_res",
// * 8 bits for "clk_seq_low",
// two most significant bits holds zero and one for variant DCE1.1
$clockSeq | 0x8000,
// 48 bits for "node"
$node
);
}
}

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Domain\Entity;
class Game extends AEntity
{
private string $srId;
private Season $season;
private \DateTimeImmutable $scheduled;
private Team $home;
private Team $away;
private string $venue_arena;
public function create(
string $srId,
Season $season,
\DateTimeImmutable $scheduled,
Team $home,
Team $away,
string $venue_arena,
): void {
$this->srId = $srId;
$this->season = $season;
$this->scheduled = $scheduled;
$this->home = $home;
$this->away = $away;
$this->venue_arena = $venue_arena;
}
}

View File

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Domain\Entity;
class League extends AEntity
{
private string $name;
public function create(
string $name,
): void {
$this->name = $name;
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Domain\Entity;
class Season extends AEntity
{
private League $league;
private string $type; // should be an enum
private int $year;
public function create(
League $league,
string $type,
int $year,
): void {
$this->league = $league;
$this->type = $type;
$this->year = $year;
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Domain\Entity;
class Team extends AEntity
{
private string $name;
private string $alias;
private string $sr_id;
public function create(
string $name,
string $alias,
string $sr_id,
): void {
$this->name = $name;
$this->alias = $alias;
$this->sr_id = $sr_id;
}
}

0
src/Infrastructure/Console/.gitignore vendored Normal file
View File

View File

@ -0,0 +1,24 @@
<?php
namespace App\Infrastructure\Console;
use App\Application\CommandBus\UseCaseCommandBus;
use App\Application\UseCase\GetNHLMatchRequest;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
#[AsCommand(name: 'app:get-nhl-schedule')]
class GetNHLScheduleCommand
{
public function __construct(
private readonly UseCaseCommandBus $commandBus,
) {
}
public function __invoke(): int
{
$this->commandBus->ask(new GetNHLMatchRequest('REG'));
return Command::SUCCESS;
}
}

View File

View File

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\SportRadar;
use App\Application\SportRadar\SportRadarEngine;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class HTTPSportRadarEngine implements SportRadarEngine
{
public function __construct(
private HttpClientInterface $client,
#[Autowire('%app.sport_radar_apikey%')]
string $apiKey,
) {
$this->client = $client->withOptions(
[
'headers' => [
'x-api-key' => $apiKey,
'accept' => 'application/json',
],
],
);
}
public function getNHLSchedule(): void
{
$response = $this->client->request('GET', 'https://api.sportradar.com/nhl/trial/v7/en/games/2025/REG/schedule.json');
// return ['a'];
}
}