Skip to content

Commit

Permalink
Merge pull request #303 from vtsykun/feat/composer-push
Browse files Browse the repository at this point in the history
Allow to publish artifact package via API or composer push plugin
vtsykun authored Jan 22, 2025
2 parents c4d67cb + 951f0cd commit a4b4c90
Showing 21 changed files with 626 additions and 40 deletions.
2 changes: 1 addition & 1 deletion config/packages/security.yaml
Original file line number Diff line number Diff line change
@@ -23,7 +23,7 @@ security:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
packages:
pattern: (^(.+\.json$|/p/|/mirror/|/zipball/|/feeds/.+(\.rss|\.atom)|/packages/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+?(\.json|/changelog)|/packages/list\.json|/downloads/|/api/))+
pattern: (^(.+\.json$|/p/|/mirror/|/zipball/|/feeds/.+(\.rss|\.atom)|/packages/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+?(\.json|/changelog)|/packages/list\.json|/packages/upload/|/downloads/|/api/))+
api_basic:
provider: all_users
stateless: true
15 changes: 12 additions & 3 deletions src/Composer/Repository/ArtifactRepository.php
Original file line number Diff line number Diff line change
@@ -31,7 +31,11 @@ class ArtifactRepository extends ArrayRepository implements PacketonRepositoryIn
protected $lookup;

/** @var array */
protected $archives;
protected array $archives = [];

protected array $archivesOverwriteInfo = [];

protected array $archivesOverwriteInfoByRef = [];

public function __construct(
protected array $repoConfig,
@@ -51,6 +55,7 @@ public function __construct(
$this->lookup = $repoConfig['url'] ?? null;
$this->lookup = $this->lookup === '_unset' ? null : $this->lookup;
$this->archives = $this->repoConfig['archives'] ?? [];
$this->archivesOverwriteInfo = $this->repoConfig['archive_overwrite_mapping'] ?? [];

$this->process ??= new ProcessExecutor($this->io);
}
@@ -136,7 +141,7 @@ public function allArtifacts(): iterable
private function doInitialize(): void
{
foreach ($this->allArtifacts() as $ref => $file) {
$package = $this->getComposerInformation($file, $ref);
$package = $this->getComposerInformation($file, $ref, $this->archivesOverwriteInfoByRef[$ref] ?? null);
if (!$package) {
$this->io->writeError("File <comment>{$file->getBasename()}</comment> doesn't seem to hold a package", true, IOInterface::VERBOSE);
continue;
@@ -164,6 +169,8 @@ private function scanArchives(array $archives): iterable
continue;
}

$this->archivesOverwriteInfoByRef[$zip->getReference()] = $this->archivesOverwriteInfo[$zip->getId()] ?? null;

yield $zip->getReference() => new \SplFileInfo($path);
}
}
@@ -190,7 +197,7 @@ private function scanDirectory(string $path): iterable
/**
* {@inheritdoc}
*/
public function getComposerInformation(\SplFileInfo $file, $ref = null): ?BasePackage
public function getComposerInformation(\SplFileInfo $file, mixed $ref = null, ?array $overwrite = null): ?BasePackage
{
$json = null;
$fileExtension = pathinfo($file->getPathname(), PATHINFO_EXTENSION);
@@ -217,6 +224,8 @@ public function getComposerInformation(\SplFileInfo $file, $ref = null): ?BasePa
}

$package = JsonFile::parseJson($json, $file->getPathname().'#composer.json');
$package = array_merge($package, $overwrite ?? []);

$package['dist'] = [
'type' => $fileType,
'url' => strtr($file->getPathname(), '\\', '/'),
9 changes: 2 additions & 7 deletions src/Controller/PackageController.php
Original file line number Diff line number Diff line change
@@ -281,7 +281,6 @@ public function viewVendorAction($vendor)
]);
}


#[Route('/providers/{name}/', name: 'view_providers', requirements: ['name' => '[A-Za-z0-9/_.-]+?'], defaults: ['_format' => 'html'], methods: ['GET'])]
#[IsGranted('ROLE_MAINTAINER')]
public function viewProvidersAction($name, \Redis $redis): Response
@@ -599,12 +598,8 @@ public function updatePackageAction(Request $req, $name): Response
$doctrine = $this->registry;
$this->checkSubrepositoryAccess($name);

try {
/** @var Package $package */
$package = $doctrine
->getRepository(Package::class)
->getPackageByName($name);
} catch (NoResultException) {
$package = $doctrine->getRepository(Package::class)->getPackageByName($name);
if (null === $package) {
return new JsonResponse(['status' => 'error', 'message' => 'Package not found'], 404);
}

42 changes: 42 additions & 0 deletions src/Controller/PushPackagesController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

namespace Packeton\Controller;

use Packeton\Exception\ValidationException;
use Packeton\Form\Handler\PushPackageHandler;
use Packeton\Form\Type\Push\NexusPushType;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

#[Route(defaults: ['_format' => 'json'])]
class PushPackagesController extends AbstractController
{
#[IsGranted('ROLE_MAINTAINER')]
#[Route('/packages/upload/{name}/{version}', name: 'package_push_nexus', requirements: ['name' => '%package_name_regex%'], methods: ['PUT', 'POST'])]
#[Route('/api/packages/upload/{name}/{version}', name: 'package_push_api', requirements: ['name' => '%package_name_regex%'], methods: ['PUT'])]
public function pushNexusAction(PushPackageHandler $handler, Request $request, string $name, string $version): Response
{
$form = $this->createApiForm(NexusPushType::class, options: ['method' => $request->getMethod()]);

try {
$handler($form, $request, $name, $version, $this->getUser());
} catch (ValidationException $e) {
return new JsonResponse(['title' => $e->getMessage(), 'errors' => $e->getFormErrors()], 400);
}

return new JsonResponse([], 201);
}

protected function createApiForm(string $type, mixed $data = null, array $options = []): FormInterface
{
$options['csrf_protection'] = false;
return $this->container->get('form.factory')->createNamed('', $type, $data, $options);
}
}
12 changes: 9 additions & 3 deletions src/Controller/ZipballController.php
Original file line number Diff line number Diff line change
@@ -9,6 +9,7 @@
use Packeton\Entity\Package;
use Packeton\Entity\Zipball;
use Packeton\Event\ZipballEvent;
use Packeton\Exception\ZipballException;
use Packeton\Model\UploadZipballStorage;
use Packeton\Package\RepTypes;
use Packeton\Service\DistManager;
@@ -40,7 +41,7 @@ public function __construct(
) {
}

#[Route('/archive/upload', name: 'archive_upload', methods: ["POST"])]
#[Route('/archive/upload', name: 'archive_upload', methods: ["POST"], format: 'json')]
#[IsGranted('ROLE_MAINTAINER')]
public function upload(Request $request): Response
{
@@ -52,8 +53,13 @@ public function upload(Request $request): Response
return new JsonResponse(['error' => 'File is empty'], 400);
}

$result = $this->storage->save($file);
return new JsonResponse($result, $result['code'] ?? 201);
try {
$result = $this->storage->save($file);
} catch (ZipballException $e) {
return new JsonResponse(['error' => $e->getMessage()], max($e->getCode(), 300));
}

return new JsonResponse(['id' => $result->getId(), 'filename' => $result->getFilename(), 'size' => $result->getFileSize()], 201);
}

#[Route('/archive/remove/{id}', name: 'archive_remove', methods: ["DELETE"])]
1 change: 1 addition & 0 deletions src/Entity/Package.php
Original file line number Diff line number Diff line change
@@ -455,6 +455,7 @@ public function getRepoConfig(): array
'repoType' => $this->repoType ?: 'vcs',
'subDirectory' => $this->getSubDirectory(),
'archives' => $this->getArchives(),
'archive_overwrite_mapping' => $this->getArchiveOverwrite(),
'oauth2' => $this->integration,
'externalRef' => $this->externalRef,
'customVersions' => $this->getCustomVersions(),
44 changes: 43 additions & 1 deletion src/Entity/PackageSerializedTrait.php
Original file line number Diff line number Diff line change
@@ -6,6 +6,7 @@

use Composer\Package\Version\VersionParser;
use Composer\Semver\Constraint\Constraint;
use Packeton\Util\PacketonUtils;

trait PackageSerializedTrait
{
@@ -76,7 +77,7 @@ public function getArchives(): ?array
return $this->serializedFields['archives'] ?? null;
}

public function getAllArchives(): ?array
public function getAllArchives(): array
{
$archives = $this->serializedFields['archives'] ?? [];
foreach ($this->getCustomVersions() as $version) {
@@ -102,6 +103,38 @@ public function setArchives(?array $archives): void
$this->setSerialized('archives', $archives);
}

public function setArchiveOverwrite(int $archiveId, array $versionData): void
{
$mapping = $this->getSerialized('archive_version_mapping', 'array') ?? [];
$archives = $this->getArchives();
$version = $versionData['version'] ?? null;

$unset = [];
foreach ($mapping as $id => $data) {
if (($data['version'] ?? '_na') === $version) {
$unset[] = $id;
}
}

$archives[] = $archiveId;
$archives = array_diff($archives, $unset);
$this->setArchives(array_values(array_unique($archives)));

$mapping[$archiveId] = $versionData;
foreach ($mapping as $id => $data) {
if (!in_array($id, $archives)) {
unset($mapping[$id]);
}
}

$this->setSerialized('archive_version_mapping', $mapping);
}

public function getArchiveOverwrite(): array
{
return $this->getSerialized('archive_version_mapping', 'array') ?? [];
}

public function setUpdateFlags(int $flags): void
{
$this->setSerialized('update_flags', $flags);
@@ -199,6 +232,15 @@ public function setCustomVersions($versions): void
$this->setSerialized('custom_versions', $versions);
}

public function addCustomVersions(array $versionData): void
{
$versions = $this->getCustomVersions();
$versions = PacketonUtils::buildChoices($versions, 'version');
$versions[$versionData['version']] = $versionData;

$this->setCustomVersions(array_values($versions));
}

public function getCustomComposerJson(): array
{
return $this->getSerialized('custom_composer_json', 'array', []);
44 changes: 44 additions & 0 deletions src/Exception/ValidationException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

declare(strict_types=1);

namespace Packeton\Exception;

use Symfony\Component\Form\FormInterface;

class ValidationException extends \RuntimeException implements DebugHttpExceptionInterface
{
private array $errors = [];

public static function create(string $message, FormInterface|array $errors = [], ?\Throwable $previous = null): static
{
$exception = new static($message, 400, $previous);
$exception->errors = $errors instanceof FormInterface ? $exception->getErrors($errors) : $errors;

return $exception;
}

private function getErrors(FormInterface $form): array
{
$errors = $base = [];

foreach ($form->getErrors() as $error) {
$base[] = $error->getMessage();
}
foreach ($form as $child) {
foreach ($child->getErrors(true) as $error) {
$errors[$child->getName()] = $error->getMessage();
}
}
if (count($base) > 0) {
$errors['root'] = implode("\n", $base);
}

return $errors;
}

public function getFormErrors(): array
{
return $this->errors;
}
}
9 changes: 9 additions & 0 deletions src/Exception/ZipballException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace Packeton\Exception;

class ZipballException extends \RuntimeException implements DebugHttpExceptionInterface
{
}
81 changes: 81 additions & 0 deletions src/Form/Handler/PushPackageHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php

declare(strict_types=1);

namespace Packeton\Form\Handler;

use Doctrine\Persistence\ManagerRegistry;
use Packeton\Entity\Package;
use Packeton\Entity\User;
use Packeton\Exception\ValidationException;
use Packeton\Exception\ZipballException;
use Packeton\Form\Model\PushRequestDtoInterface;
use Packeton\Model\PackageManager;
use Packeton\Package\RepTypes;
use Packeton\Repository\PackageRepository;
use Packeton\Service\Artifact\ArtifactPushHandler;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\User\UserInterface;

class PushPackageHandler
{
public function __construct(
private readonly ManagerRegistry $registry,
private readonly PackageManager $packageManager,
private readonly ArtifactPushHandler $handler,
) {
}

public function __invoke(FormInterface $form, Request $request, string $name, ?string $version = null, ?UserInterface $user = null): void
{
$request->request->set('version', $version ?? 'dev-master');

$form->handleRequest($request);
if (!$form->isSubmitted() || !$form->isValid()) {
throw ValidationException::create("Validation errors", $form);
}

/** @var PushRequestDtoInterface $dtoRequest */
$dtoRequest = $form->getData();
$package = $this->getRepo()->getPackageByName($name);
if (null === $package) {
$package = $this->createArtifactPackage($name, $user);
}

try {
$this->handler->addVersion($dtoRequest, $package);
} catch (ZipballException $e) {
throw ValidationException::create($e->getMessage(), previous: $e);
}
}

private function getRepo(): PackageRepository
{
return $this->registry->getRepository(Package::class);
}

private function createArtifactPackage(string $name, ?UserInterface $user = null): Package
{
$em = $this->registry->getManager();
$package = new Package();
$package->setName($name);
$package->setRepoType(RepTypes::CUSTOM);
$package->setRepositoryPath(null);

$user = null !== $user ?
$this->registry->getRepository(User::class)->findOneBy(['username' => $user->getUserIdentifier()])
: $user;

if ($user instanceof User) {
$package->addMaintainer($user);
}

$em->persist($package);
$em->flush();

$this->packageManager->insetPackage($package);

return $package;
}
}
Loading

0 comments on commit a4b4c90

Please sign in to comment.