feat(shared) : infra upload générique (ERP-154) (#108)
Auto Tag Develop / tag (push) Successful in 8s
Auto Tag Develop / tag (push) Successful in 8s
Infra d'upload de fichiers générique et réutilisable dans `Shared` (spec M4 § 2.7). Ne touche pas au module Transport.
## Livré
- **Table `uploaded_document`** (migration racine `DoctrineMigrations`) : fichier téléversé immuable (PDF / images) — `original_filename`, `stored_path`, `mime_type`, `size_bytes`, `checksum` (sha256), `created_at`, `created_by`. COMMENT ON COLUMN sur toutes les colonnes + bloc dans `ColumnCommentsCatalog`.
- **Service `Shared\Infrastructure\Upload\FileUploader`** : validation MIME server-side via `getMimeType()` (jamais `getClientMimeType()`), whitelist explicite (PDF + images), bornage taille (10 Mo), checksum sha256, écriture disque `var/uploads/{yyyy}/{mm}/`.
- **Endpoint `POST /api/uploaded_documents`** (multipart, `deserialize:false`) + `UploadedDocumentProcessor` -> renvoie l'IRI ; MIME hors whitelist -> 422.
- Wiring : mapping Doctrine `Shared` + path API Platform `Shared`.
## Tests
- `FileUploaderTest` (unitaire) + `UploadedDocumentApiTest` (fonctionnel : 201/IRI/checksum, 422 MIME interdit, 422 sans fichier, 401 anonyme).
`make test` vert (701 tests), `php-cs-fixer` propre.
## Hors scope
Pas d'antivirus / S3 / purge (§ 9). Pas de `carrier.discharge_document_id` (ticket consommateur M4).
Ticket ERP-154.
---------
Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #108
This commit was merged in pull request #108.
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Shared\Infrastructure\ApiPlatform\State\UploadedDocumentProcessor;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
/**
|
||||
* Reference technique d'un fichier televerse (infra generique Shared, ERP-154).
|
||||
*
|
||||
* Entite IMMUABLE : un document n'est jamais modifie apres creation (pas d'onglet
|
||||
* edition cote front). Elle ne porte donc QUE `created_at` / `created_by` — pas
|
||||
* la paire `updated_*` — et n'implemente volontairement pas Timestampable /
|
||||
* Blamable (qui imposeraient les 4 colonnes). `created_at` est rempli par le
|
||||
* FileUploader via l'horloge injectee ; `created_by` est positionne par le
|
||||
* processor depuis l'utilisateur authentifie (null hors HTTP).
|
||||
*
|
||||
* Pas de `#[Auditable]` : c'est un enregistrement d'infrastructure (et non un
|
||||
* agregat metier edite), sa tracabilite est portee par created_at / created_by.
|
||||
*
|
||||
* Operations API :
|
||||
* - Post (/uploaded_documents, multipart) : `deserialize: false` — le binaire
|
||||
* n'est pas deserialise dans l'entite, le UploadedDocumentProcessor lit le
|
||||
* fichier de la requete, delegue au FileUploader (validation MIME server-side,
|
||||
* bornage taille, checksum, ecriture disque) puis persiste. MIME hors
|
||||
* whitelist -> 422.
|
||||
* - Get (/uploaded_documents/{id}) : necessaire pour qu'API Platform genere
|
||||
* l'IRI renvoyee par le Post. Protege par IS_AUTHENTICATED_FULLY uniquement
|
||||
* (pas de RBAC ni de cloisonnement tenant ici) : cette ressource est une
|
||||
* infra GENERIQUE qui ne porte aucune notion de proprietaire metier. Le
|
||||
* cloisonnement d'acces (qui peut voir quel document) est volontairement
|
||||
* delegue au module CONSOMMATEUR (ex: la Decharge M4), qui exposera le
|
||||
* document via sa propre ressource cloisonnee plutot que via cet endpoint
|
||||
* technique. Ne renvoie que des metadonnees (jamais le binaire).
|
||||
*
|
||||
* Pas de GetCollection exposee (non requise) — la regle de pagination ne
|
||||
* s'applique donc pas ici.
|
||||
*/
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'uploaded_document')]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
security: "is_granted('IS_AUTHENTICATED_FULLY')",
|
||||
),
|
||||
new Post(
|
||||
// Entree multipart : le binaire arrive en multipart/form-data sous
|
||||
// le champ « file ». Sans cet inputFormats, API Platform rejette la
|
||||
// requete en 415.
|
||||
inputFormats: ['multipart' => ['multipart/form-data']],
|
||||
// Le fichier n'est pas deserialisable dans l'entite : le processor
|
||||
// lit le binaire de la requete. La validation est portee par le
|
||||
// FileUploader (MIME server-side, taille), pas par les contraintes.
|
||||
deserialize: false,
|
||||
validate: false,
|
||||
security: "is_granted('IS_AUTHENTICATED_FULLY')",
|
||||
processor: UploadedDocumentProcessor::class,
|
||||
),
|
||||
],
|
||||
normalizationContext: ['groups' => ['uploaded_document:read']],
|
||||
)]
|
||||
class UploadedDocument
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
#[Groups(['uploaded_document:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(name: 'original_filename', length: 255)]
|
||||
#[Groups(['uploaded_document:read'])]
|
||||
private string $originalFilename;
|
||||
|
||||
#[ORM\Column(name: 'stored_path', length: 512)]
|
||||
#[Groups(['uploaded_document:read'])]
|
||||
private string $storedPath;
|
||||
|
||||
#[ORM\Column(name: 'mime_type', length: 100)]
|
||||
#[Groups(['uploaded_document:read'])]
|
||||
private string $mimeType;
|
||||
|
||||
#[ORM\Column(name: 'size_bytes', type: 'integer')]
|
||||
#[Groups(['uploaded_document:read'])]
|
||||
private int $sizeBytes;
|
||||
|
||||
#[ORM\Column(name: 'checksum', length: 64)]
|
||||
#[Groups(['uploaded_document:read'])]
|
||||
private string $checksum;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'datetime_immutable')]
|
||||
#[Groups(['uploaded_document:read'])]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
|
||||
#[ORM\JoinColumn(name: 'created_by', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?UserInterface $createdBy = null;
|
||||
|
||||
public function __construct(
|
||||
string $originalFilename,
|
||||
string $storedPath,
|
||||
string $mimeType,
|
||||
int $sizeBytes,
|
||||
string $checksum,
|
||||
DateTimeImmutable $createdAt,
|
||||
) {
|
||||
$this->originalFilename = $originalFilename;
|
||||
$this->storedPath = $storedPath;
|
||||
$this->mimeType = $mimeType;
|
||||
$this->sizeBytes = $sizeBytes;
|
||||
$this->checksum = $checksum;
|
||||
$this->createdAt = $createdAt;
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getOriginalFilename(): string
|
||||
{
|
||||
return $this->originalFilename;
|
||||
}
|
||||
|
||||
public function getStoredPath(): string
|
||||
{
|
||||
return $this->storedPath;
|
||||
}
|
||||
|
||||
public function getMimeType(): string
|
||||
{
|
||||
return $this->mimeType;
|
||||
}
|
||||
|
||||
public function getSizeBytes(): int
|
||||
{
|
||||
return $this->sizeBytes;
|
||||
}
|
||||
|
||||
public function getChecksum(): string
|
||||
{
|
||||
return $this->checksum;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function getCreatedBy(): ?UserInterface
|
||||
{
|
||||
return $this->createdBy;
|
||||
}
|
||||
|
||||
public function setCreatedBy(?UserInterface $user): void
|
||||
{
|
||||
$this->createdBy = $user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Exception;
|
||||
|
||||
/**
|
||||
* Levee quand le fichier televerse depasse la taille maximale autorisee
|
||||
* (FileUploader::MAX_SIZE_BYTES). Traduite en HTTP 422 par le processor.
|
||||
*/
|
||||
final class FileTooLargeException extends FileUploadException
|
||||
{
|
||||
public function __construct(int $size, int $maxSize)
|
||||
{
|
||||
parent::__construct(sprintf(
|
||||
'Le fichier (%d octets) dépasse la taille maximale autorisée (%d octets).',
|
||||
$size,
|
||||
$maxSize,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Exception;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Exception de base des erreurs de televersement (FileUploader).
|
||||
*
|
||||
* Decouplee de HTTP : le service Shared\Infrastructure\Upload\FileUploader leve
|
||||
* une de ces exceptions metier, et c'est la couche API (UploadedDocumentProcessor)
|
||||
* qui la traduit en reponse HTTP 422.
|
||||
*/
|
||||
class FileUploadException extends RuntimeException {}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Exception;
|
||||
|
||||
/**
|
||||
* Levee quand le type MIME detecte server-side n'appartient pas a la whitelist
|
||||
* du FileUploader (PDF + images). Traduite en HTTP 422 par le processor.
|
||||
*/
|
||||
final class UnsupportedMimeTypeException extends FileUploadException
|
||||
{
|
||||
/**
|
||||
* @param list<string> $allowed Types MIME autorises
|
||||
*/
|
||||
public function __construct(string $mimeType, array $allowed)
|
||||
{
|
||||
parent::__construct(sprintf(
|
||||
'Le type de fichier « %s » n\'est pas autorisé. Types acceptés : %s.',
|
||||
$mimeType,
|
||||
implode(', ', $allowed),
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user