Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,7 @@ jobs:
composer config extra.symfony.require ${{ matrix.symfony }}
composer config minimum-stability dev
composer config prefer-stable false
composer require --dev --no-update --ansi 'phpunit/phpunit:^13.3@stable' 'brianium/paratest:^7.26@stable'
composer update --no-progress --ansi
- name: Flag held back Symfony packages
env:
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,15 @@ Every change that reaches `main` adds a line under **Unreleased**. When a versio

## Unreleased

### Breaking
- An uploadable resource's item upload (`POST {iri}/upload`) takes its `security`, `securityPostDenormalize` and `securityPostValidation` (with their messages) from the resource's `Patch`, else its `Put`, instead of its `Get`, and a resource with neither has no item upload route; the download still follows `Get` ([#584](https://github.com/components-web-app/api-components-bundle/pull/584))

### Fixed
- `GET /_/contexts/<ShortName>` (the JSON-LD context) no longer fails with a TypeError in the publishable response listener ([#583](https://github.com/components-web-app/api-components-bundle/pull/583))

### Tooling
- The Symfony NEXT CI leg keeps PHPUnit and ParaTest on their stable releases, so it tests the next Symfony rather than unreleased test tools ([#584](https://github.com/components-web-app/api-components-bundle/pull/584))

## [2.0.0-alpha.40](https://github.com/components-web-app/api-components-bundle/compare/2.0.0-alpha.39...2.0.0-alpha.40) - 2026-10-31

### Breaking
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,7 @@ Resources are fetched and cached **individually**. Never embed related data outs
- **A variant's field reaches `FlysystemCacheResolver::store()` by its source path.** `MediaObjectFactory` and `UploadableFileManager::storeFilesMetadata()` call `UploadableCacheControl::registerVariantSource($path, $field)` before generating, and the resolver reads `forVariantOf($path)`, which falls back to the bundle value (as for liip's own filter route). Anything new that generates variants must register first. The map is cleared on `kernel.reset`. `ImagineCompilerPass` injects `$cacheControl` into every tagged `FlysystemCacheResolver` unless the application set it.
- **A copy passes no `Cache-Control`; the stores keep the source's.** GCS copies metadata when the request sets none, the AWS SDK adapter copies with `MetadataDirective: COPY`, and AsyncAws's `copy()` ignores its config entirely. A clone of a file stored before #414 therefore has none until backfilled.
- **`#[UploadedImage]` (#455) is `Assert\Image` for an upload field**, a subclass, so every `Image` option still applies and the orphaned file report's `StoredFileConstraintValidatorFactory` (routing on `instanceof File`) reaches it. Its validator extends `ImageValidator`. Both validators resolve it through `UploadedImage::forFile()`, the one place that decides it: an SVG has every dimension, pixel, ratio, shape and `detectCorrupted` option dropped (only the file checks run), a raster image takes `maxPixels` from `uploadable.image_max_pixels` when the constraint sets none, and `{{ max_width }}`/`{{ max_height }}` (a 3:2 approximation) are written into the message text before validating. `allowSvg: false` refuses an SVG before any other check. The configured limit reaches the upload validator service and `StoredFileConstraintChecker`'s `$defaultMaxPixels`; the scan builds its own validators, so anything else that resolves the constraint must be given it too. There is no `allowNull: false`: `file` is null on every write that does not upload, and `requiredOnPublish` is how a file is required.
- **An item upload (`POST {iri}/upload`) is a write, so it carries the resource's `Patch` security, else its `Put`'s** (#581): `UploadableResourceMetadataCollectionFactory` builds it from the `Get` (provider, contexts, controller) and copies `security`, `securityPostDenormalize`, `securityPostValidation` and their messages from the write operation, all six, including nulls. A resource with neither gets no item upload route rather than one guarded by a read's security; the collection upload copies the `Post`, and the download (a read) keeps the `Get`'s. #569's content write security is applied on top for CWA content, because its factory decorates further out. `Uploads\UploadWriteSecurityTest` pins it on a resource that is not CWA content.
- Each `UploadableField` needs a **distinct `property:`**; `UploadableAttributeReader` throws when two share one (#199). Imagine runs only on raster images.
- `requiredOnPublish` adds a class-level `RequiresUploadedFile` in the groups publishing validates: `{ShortName}:published`, or the class's custom `#[Publishable(validationGroups:)]`, which replace it (#193, #252). **Those groups are decided only by `PublishableAttributeReader::getPublishedValidationGroups()`**, which `PublishableValidator` and the orphaned files scan both call.
- `urlGenerator: 'public'` asks the Filesystem (honouring the tag's `public_url` config) and falls back to `api` only on `UnableToGeneratePublicUrl` (#257).
Expand Down
2 changes: 1 addition & 1 deletion phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ parameters:
-
message: '#^Attribute class JetBrains\\PhpStorm\\Pure does not exist\.$#'
identifier: attribute.notFound
count: 5
count: 4
path: src/ApiPlatform/Metadata/Resource/UploadableResourceMetadataCollectionFactory.php

-
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,12 @@
use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\Metadata;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Operation\PathSegmentNameGeneratorInterface;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
use JetBrains\PhpStorm\Pure;
Expand All @@ -29,6 +33,15 @@
*/
class UploadableResourceMetadataCollectionFactory implements ResourceMetadataCollectionFactoryInterface
{
private const array WRITE_SECURITY_PROPERTIES = [
'security',
'securityMessage',
'securityPostDenormalize',
'securityPostDenormalizeMessage',
'securityPostValidation',
'securityPostValidationMessage',
];

private ResourceMetadataCollectionFactoryInterface $decorated;
private UploadableAttributeReaderInterface $uploadableFileManager;
private PathSegmentNameGeneratorInterface $pathSegmentNameGenerator;
Expand Down Expand Up @@ -64,15 +77,18 @@ public function create(string $resourceClass): ResourceMetadataCollection
if (!$operations) {
continue;
}
$itemWriteOperation = self::findItemWriteOperation($operations);
foreach ($operations as $operation) {
if ($operation instanceof Post) {
$postUploadOperation = static::generatePostOperation($operation, $openApiRequestMultipartProperties, $pathSegmentName);
$operations->add(self::generateOperationName($postUploadOperation), $postUploadOperation);
}
if ($operation instanceof Get) {
$uploadItemOperation = self::generateUploadItemOperation($operation, $openApiRequestMultipartProperties, $pathSegmentName);
$uploadName = self::generateOperationName($uploadItemOperation);
$operations->add($uploadName, $uploadItemOperation->withName($uploadName));
if (null !== $itemWriteOperation) {
$uploadItemOperation = self::generateUploadItemOperation($operation, $itemWriteOperation, $openApiRequestMultipartProperties, $pathSegmentName);
$uploadName = self::generateOperationName($uploadItemOperation);
$operations->add($uploadName, $uploadItemOperation->withName($uploadName));
}

$downloadItemOperation = self::generateDownloadItemOperation($operation, $pathSegmentName);
$downloadName = self::generateOperationName($downloadItemOperation);
Expand All @@ -84,6 +100,35 @@ public function create(string $resourceClass): ResourceMetadataCollection
return $resourceMetadata;
}

/**
* @param iterable<Operation> $operations
*/
private static function findItemWriteOperation(iterable $operations): ?HttpOperation
{
$put = null;
foreach ($operations as $operation) {
if ($operation instanceof Patch) {
return $operation;
}
if (null === $put && $operation instanceof Put) {
$put = $operation;
}
}

return $put;
}

private static function withWriteSecurity(HttpOperation $operation, HttpOperation $writeOperation): HttpOperation
{
$secured = clone $operation;
foreach (self::WRITE_SECURITY_PROPERTIES as $property) {
$reflectionProperty = new \ReflectionProperty(Metadata::class, $property);
$reflectionProperty->setValue($secured, $reflectionProperty->getValue($writeOperation));
}

return $secured;
}

#[Pure]
private static function generateOperationName(HttpOperation $operation): string
{
Expand Down Expand Up @@ -116,11 +161,10 @@ private static function generatePostOperation(Post $defaultOperation, array $ope
return self::configurePostOperation($newPost, $openApiRequestMultipartProperties);
}

#[Pure]
private static function generateUploadItemOperation(Get $getOperation, array $openApiRequestMultipartProperties, string $pathSegmentName): HttpOperation
private static function generateUploadItemOperation(Get $getOperation, HttpOperation $writeOperation, array $openApiRequestMultipartProperties, string $pathSegmentName): HttpOperation
{
$path = \sprintf('/%s/{id}/upload', $pathSegmentName);
$newUploadPost = $getOperation
$newUploadPost = self::withWriteSecurity($getOperation, $writeOperation)
->withUriTemplate($path)
->withMethod(HttpOperation::METHOD_POST)
->withShortName($getOperation->getShortName())
Expand Down
91 changes: 88 additions & 3 deletions tests/ApiPlatform/Metadata/Resource/StateOperationMetadataTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Operation\PathSegmentNameGeneratorInterface;
use ApiPlatform\Metadata\Operations;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Put;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
Expand Down Expand Up @@ -129,7 +131,7 @@ public function test_the_upload_and_download_operations_are_marked_for_their_pro
$reader->method('getConfiguredProperties')->willReturn([]);
$segments = $this->createStub(PathSegmentNameGeneratorInterface::class);
$segments->method('getSegmentName')->willReturn('dummy_uploadables');
$factory = new UploadableResourceMetadataCollectionFactory($this->decorated(DummyUploadable::class, 'DummyUploadable', ['kept' => 1]), $reader, $segments);
$factory = new UploadableResourceMetadataCollectionFactory($this->decorated(DummyUploadable::class, 'DummyUploadable', ['kept' => 1], [new Patch(uriTemplate: '/items/{id}', shortName: 'DummyUploadable', class: DummyUploadable::class, name: 'item_patch')]), $reader, $segments);

$metadata = $factory->create(DummyUploadable::class);
$upload = $metadata->getOperation('_api_/dummy_uploadables/{id}/upload_post');
Expand All @@ -143,15 +145,98 @@ public function test_the_upload_and_download_operations_are_marked_for_their_pro
self::assertFalse($download->canSerialize());
}

public function test_the_upload_operation_takes_every_security_of_the_patch_operation_and_the_download_keeps_the_gets(): void
{
$metadata = $this->uploadableFactory([
self::securedGet(),
new Put(uriTemplate: '/items/{id}', shortName: 'DummyUploadable', class: DummyUploadable::class, name: 'item_put', security: 'put', securityMessage: 'put message'),
new Patch(uriTemplate: '/items/{id}', shortName: 'DummyUploadable', class: DummyUploadable::class, name: 'item_patch', security: 'patch', securityMessage: 'patch message', securityPostDenormalize: 'patch denormalize', securityPostDenormalizeMessage: 'patch denormalize message', securityPostValidation: 'patch validation', securityPostValidationMessage: 'patch validation message'),
])->create(DummyUploadable::class);

self::assertSame(['patch', 'patch message', 'patch denormalize', 'patch denormalize message', 'patch validation', 'patch validation message'], self::securityOf($metadata->getOperation(self::UPLOAD)));
self::assertSame(['get', 'get message', 'get denormalize', 'get denormalize message', 'get validation', 'get validation message'], self::securityOf($metadata->getOperation(self::DOWNLOAD)));
self::assertSame('/items/{id}/upload', $metadata->getOperation(self::UPLOAD)->getUriTemplate());
self::assertSame('POST', $metadata->getOperation(self::UPLOAD)->getMethod());
}

public function test_the_upload_operation_takes_the_put_operations_security_when_there_is_no_patch(): void
{
$metadata = $this->uploadableFactory([
self::securedGet(),
new Put(uriTemplate: '/items/{id}', shortName: 'DummyUploadable', class: DummyUploadable::class, name: 'item_put', security: 'put', securityMessage: 'put message'),
new Put(uriTemplate: '/other/{id}', shortName: 'DummyUploadable', class: DummyUploadable::class, name: 'other_put', security: 'other put'),
])->create(DummyUploadable::class);

self::assertSame(['put', 'put message', null, null, null, null], self::securityOf($metadata->getOperation(self::UPLOAD)));
}

public function test_a_resource_with_no_patch_or_put_gets_no_item_upload_operation_but_keeps_its_download(): void
{
$metadata = $this->uploadableFactory([self::securedGet()])->create(DummyUploadable::class);

self::assertSame(['item_get', self::DOWNLOAD], array_keys(iterator_to_array($metadata[0]->getOperations() ?? [])));
}

private const string UPLOAD = '_api_/items/{id}/upload_post';

private const string DOWNLOAD = '_api_/items/{id}/download/{property}_get';

private static function securedGet(): Get
{
return new Get(uriTemplate: '/items/{id}', uriVariables: ['id'], shortName: 'DummyUploadable', class: DummyUploadable::class, name: 'item_get', security: 'get', securityMessage: 'get message', securityPostDenormalize: 'get denormalize', securityPostDenormalizeMessage: 'get denormalize message', securityPostValidation: 'get validation', securityPostValidationMessage: 'get validation message');
}

/**
* @return list<string|\Stringable|null>
*/
private static function securityOf(Operation $operation): array
{
return [
$operation->getSecurity(),
$operation->getSecurityMessage(),
$operation->getSecurityPostDenormalize(),
$operation->getSecurityPostDenormalizeMessage(),
$operation->getSecurityPostValidation(),
$operation->getSecurityPostValidationMessage(),
];
}

/**
* @param list<HttpOperation> $operations
*/
private function uploadableFactory(array $operations): UploadableResourceMetadataCollectionFactory
{
$reader = $this->createStub(UploadableAttributeReaderInterface::class);
$reader->method('isConfigured')->willReturn(true);
$reader->method('getConfiguredProperties')->willReturn([]);
$segments = $this->createStub(PathSegmentNameGeneratorInterface::class);
$segments->method('getSegmentName')->willReturn('items');
$decorated = $this->createStub(ResourceMetadataCollectionFactoryInterface::class);
$named = [];
foreach ($operations as $operation) {
$named[(string) $operation->getName()] = $operation;
}
$decorated->method('create')->willReturn(new ResourceMetadataCollection(DummyUploadable::class, [
(new ApiResource(shortName: 'DummyUploadable', class: DummyUploadable::class))->withOperations(new Operations($named)),
]));

return new UploadableResourceMetadataCollectionFactory($decorated, $reader, $segments);
}

/**
* @param array<string, mixed> $extraProperties
* @param list<HttpOperation> $otherOperations
*/
private function decorated(string $class, string $shortName, array $extraProperties = []): ResourceMetadataCollectionFactoryInterface
private function decorated(string $class, string $shortName, array $extraProperties = [], array $otherOperations = []): ResourceMetadataCollectionFactoryInterface
{
$get = new Get(uriTemplate: '/items/{id}', uriVariables: ['id'], shortName: $shortName, class: $class, name: 'item_get', controller: 'main_controller', extraProperties: $extraProperties);
$operations = ['item_get' => $get];
foreach ($otherOperations as $operation) {
$operations[(string) $operation->getName()] = $operation;
}
$decorated = $this->createStub(ResourceMetadataCollectionFactoryInterface::class);
$decorated->method('create')->willReturn(new ResourceMetadataCollection($class, [
(new ApiResource(shortName: $shortName, class: $class))->withOperations(new Operations(['item_get' => $get])),
(new ApiResource(shortName: $shortName, class: $class))->withOperations(new Operations($operations)),
]));

return $decorated;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

/*
* This file is part of the Silverback API Components Bundle Project
*
* (c) Daniel West <daniel@silverback.is>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Silverback\ApiComponentsBundle\Tests\Functional\TestBundle\Entity;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Patch;
use Doctrine\ORM\Mapping as ORM;
use Silverback\ApiComponentsBundle\Annotation as Silverback;
use Silverback\ApiComponentsBundle\Entity\Utility\IdTrait;
use Silverback\ApiComponentsBundle\Entity\Utility\UploadableTrait;
use Symfony\Component\HttpFoundation\File\File;

#[Silverback\Uploadable]
#[ApiResource(operations: [
new Get(),
new Patch(security: "is_granted('ROLE_ADMIN')", securityMessage: 'Only an admin may write this resource.'),
])]
#[ORM\Entity]
class DummyUploadableWithAdminWrite
{
use IdTrait;
use UploadableTrait;

#[Silverback\UploadableField(adapter: 'local')]
public ?File $file = null;
}
Loading
Loading