Skip to content

Commit 3146faf

Browse files
committed
Fix #60: test and document urlGenerator public/temporary behaviour in MediaObjectFactory
- MediaObjectFactory now type-hints UploadableAttributeReaderInterface instead of concrete class - PublicUrlGenerator path: adapter must implement Flysystem PublicUrlGenerator interface; falls back to api generator otherwise - TemporaryUrlGenerator path: adapter must implement Flysystem TemporaryUrlGenerator interface; falls back to api generator otherwise - Added PublicUrlLocalFilesystemAdapter test adapter implementing PublicUrlGenerator - Added DummyUploadablePublicUrl and DummyUploadableTemporaryUrl test entities - Added 4 unit tests in MediaObjectFactoryUrlGeneratorTest covering all four fallback/non-fallback paths - Added 2 Behat scenarios covering public URL and temporary URL (fallback) paths - Added theJsonNodeShouldStartWith step to UploadsContext
1 parent 7c0157e commit 3146faf

8 files changed

Lines changed: 301 additions & 2 deletions

File tree

features/bootstrap/UploadsContext.php

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
use Silverback\ApiComponentsBundle\Entity\Utility\UploadableTrait;
2525
use Silverback\ApiComponentsBundle\Helper\Uploadable\UploadableFileManager;
2626
use Silverback\ApiComponentsBundle\Tests\Functional\TestBundle\Entity\DummyUploadableAndPublishable;
27+
use Silverback\ApiComponentsBundle\Tests\Functional\TestBundle\Entity\DummyUploadablePublicUrl;
28+
use Silverback\ApiComponentsBundle\Tests\Functional\TestBundle\Entity\DummyUploadableTemporaryUrl;
2729
use Silverback\ApiComponentsBundle\Tests\Functional\TestBundle\Entity\DummyUploadableWithImagineFilters;
2830
use Symfony\Component\HttpFoundation\File\File;
2931

@@ -106,6 +108,40 @@ public function thereIsADummyUploadableAndPublishable(bool $isDraft = false, boo
106108
return $object;
107109
}
108110

111+
/**
112+
* @Given there is a DummyUploadablePublicUrl
113+
*/
114+
public function thereIsADummyUploadablePublicUrl(): void
115+
{
116+
$object = new DummyUploadablePublicUrl();
117+
$object->file = new File(__DIR__ . '/../assets/files/image.png');
118+
$this->uploadableHelper->persistFiles($object);
119+
$this->manager->persist($object);
120+
$this->manager->flush();
121+
$this->restContext->resources['dummy_uploadable'] = $this->iriConverter->getIriFromResource($object);
122+
}
123+
124+
/**
125+
* @Given there is a DummyUploadableTemporaryUrl
126+
*/
127+
public function thereIsADummyUploadableTemporaryUrl(): void
128+
{
129+
$object = new DummyUploadableTemporaryUrl();
130+
$object->file = new File(__DIR__ . '/../assets/files/image.png');
131+
$this->uploadableHelper->persistFiles($object);
132+
$this->manager->persist($object);
133+
$this->manager->flush();
134+
$this->restContext->resources['dummy_uploadable'] = $this->iriConverter->getIriFromResource($object);
135+
}
136+
137+
/**
138+
* @Then the JSON node :node should start with :prefix
139+
*/
140+
public function theJsonNodeShouldStartWith(string $node, string $prefix): void
141+
{
142+
$this->behatchJsonContext->theJsonNodeShouldMatch($node, \sprintf('/^%s/', preg_quote($prefix, '/')));
143+
}
144+
109145
/**
110146
* @Given the resource :resource has a file :file
111147
*/

features/uploads/uploads.feature

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,20 @@ Feature: API Resources which can have files uploaded
235235
| file | @image.svg |
236236
Then the response status code should be 201
237237

238+
@loginUser
239+
Scenario: An uploadable field configured with urlGenerator public returns a direct public URL
240+
Given there is a DummyUploadablePublicUrl
241+
When I send a "GET" request to the resource "dummy_uploadable"
242+
Then the response status code should be 200
243+
And the JSON node "_metadata.mediaObjects.file[0].contentUrl" should start with "http://localhost/uploads/"
244+
245+
@loginUser
246+
Scenario: An uploadable field configured with urlGenerator temporary falls back to the API download endpoint when the adapter does not support temporary URLs
247+
Given there is a DummyUploadableTemporaryUrl
248+
When I send a "GET" request to the resource "dummy_uploadable"
249+
Then the response status code should be 200
250+
And the JSON node "_metadata.mediaObjects.file[0].contentUrl" should be a valid download link for the resource "dummy_uploadable"
251+
238252
@loginUser
239253
Scenario: A multipart file upload fires exactly one Mercure notification
240254
Given I add "Content-Type" header equal to "multipart/form-data"

src/Factory/Uploadable/MediaObjectFactory.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
use League\Flysystem\UnableToRetrieveMetadata;
1919
use Liip\ImagineBundle\Service\FilterService;
2020
use Silverback\ApiComponentsBundle\Annotation\UploadableField;
21-
use Silverback\ApiComponentsBundle\AttributeReader\UploadableAttributeReader;
21+
use Silverback\ApiComponentsBundle\AttributeReader\UploadableAttributeReaderInterface;
2222
use Silverback\ApiComponentsBundle\Entity\Core\FileInfo;
2323
use Silverback\ApiComponentsBundle\Entity\Utility\ImagineFiltersInterface;
2424
use Silverback\ApiComponentsBundle\Exception\InvalidArgumentException;
@@ -42,7 +42,7 @@ class MediaObjectFactory
4242
public function __construct(
4343
ManagerRegistry $managerRegistry,
4444
private readonly FileInfoCacheManager $fileInfoCacheManager,
45-
private readonly UploadableAttributeReader $annotationReader,
45+
private readonly UploadableAttributeReaderInterface $annotationReader,
4646
private readonly FilesystemProvider $filesystemProvider,
4747
private readonly FlysystemDataLoader $flysystemDataLoader,
4848
private readonly RequestStack $requestStack,
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Silverback API Components Bundle Project
5+
*
6+
* (c) Daniel West <daniel@silverback.is>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Silverback\ApiComponentsBundle\Tests\Factory\Uploadable;
13+
14+
use Doctrine\ORM\EntityManagerInterface;
15+
use Doctrine\ORM\Mapping\ClassMetadata;
16+
use Doctrine\Persistence\ManagerRegistry;
17+
use League\Flysystem\Config;
18+
use League\Flysystem\Filesystem;
19+
use League\Flysystem\UrlGeneration\PublicUrlGenerator as FlysystemPublicUrlGenerator;
20+
use League\Flysystem\UrlGeneration\TemporaryUrlGenerator as FlysystemTemporaryUrlGenerator;
21+
use PHPUnit\Framework\TestCase;
22+
use Silverback\ApiComponentsBundle\Annotation\UploadableField;
23+
use Silverback\ApiComponentsBundle\AttributeReader\UploadableAttributeReaderInterface;
24+
use Silverback\ApiComponentsBundle\Entity\Core\FileInfo;
25+
use Silverback\ApiComponentsBundle\Factory\Uploadable\ApiUrlGenerator;
26+
use Silverback\ApiComponentsBundle\Factory\Uploadable\MediaObjectFactory;
27+
use Silverback\ApiComponentsBundle\Factory\Uploadable\PublicUrlGenerator;
28+
use Silverback\ApiComponentsBundle\Factory\Uploadable\TemporaryUrlGenerator;
29+
use Silverback\ApiComponentsBundle\Flysystem\FilesystemFactory;
30+
use Silverback\ApiComponentsBundle\Flysystem\FilesystemProvider;
31+
use Silverback\ApiComponentsBundle\Helper\Uploadable\FileInfoCacheManager;
32+
use Silverback\ApiComponentsBundle\Imagine\FlysystemDataLoader;
33+
use Symfony\Component\DependencyInjection\ServiceLocator;
34+
use Symfony\Component\HttpFoundation\RequestStack;
35+
use Symfony\Component\HttpFoundation\UrlHelper;
36+
37+
class MediaObjectFactoryUrlGeneratorTest extends TestCase
38+
{
39+
private const FILE_PATH = 'uploads/test-file.png';
40+
41+
private function buildFactory(string $urlGeneratorReference, object $adapter, Filesystem $filesystem): MediaObjectFactory
42+
{
43+
$fieldConfig = new UploadableField(adapter: 'test_adapter', urlGenerator: $urlGeneratorReference);
44+
$fieldConfig->property = 'filename';
45+
46+
$annotationReader = $this->createMock(UploadableAttributeReaderInterface::class);
47+
$annotationReader->method('getConfiguredProperties')->willReturn(['file' => $fieldConfig]);
48+
49+
$classMetadata = $this->createMock(ClassMetadata::class);
50+
$classMetadata->method('getFieldValue')->willReturn(self::FILE_PATH);
51+
52+
$em = $this->createMock(EntityManagerInterface::class);
53+
$em->method('getClassMetadata')->willReturn($classMetadata);
54+
55+
$registry = $this->createMock(ManagerRegistry::class);
56+
$registry->method('getManagerForClass')->willReturn($em);
57+
58+
$filesystemProvider = $this->createMock(FilesystemProvider::class);
59+
$filesystemProvider->method('getFilesystem')->willReturn($filesystem);
60+
61+
$filesystemFactory = $this->createMock(FilesystemFactory::class);
62+
$filesystemFactory->method('getAdapter')->willReturn($adapter);
63+
64+
$fileInfo = new FileInfo(self::FILE_PATH, 'image/png', 1024, 100, 100);
65+
$fileInfoCacheManager = $this->createMock(FileInfoCacheManager::class);
66+
$fileInfoCacheManager->method('resolveCache')->willReturn($fileInfo);
67+
68+
$apiGenerator = $this->createMock(ApiUrlGenerator::class);
69+
$apiGenerator->method('generateUrl')->willReturn('http://example.com/api/download');
70+
71+
$urlGenerators = new ServiceLocator([
72+
'api' => static fn () => $apiGenerator,
73+
'public' => static fn () => new PublicUrlGenerator(),
74+
'temporary' => static fn () => new TemporaryUrlGenerator(),
75+
]);
76+
77+
return new MediaObjectFactory(
78+
$registry,
79+
$fileInfoCacheManager,
80+
$annotationReader,
81+
$filesystemProvider,
82+
$this->createStub(FlysystemDataLoader::class),
83+
new RequestStack(),
84+
$filesystemFactory,
85+
new UrlHelper(new RequestStack()),
86+
$urlGenerators,
87+
);
88+
}
89+
90+
public function test_uses_public_url_when_adapter_supports_public_url_generator(): void
91+
{
92+
$adapter = new class implements FlysystemPublicUrlGenerator {
93+
public function publicUrl(string $path, Config $config): string
94+
{
95+
return 'https://cdn.example.com/' . $path;
96+
}
97+
};
98+
99+
$filesystem = $this->createMock(Filesystem::class);
100+
$filesystem->method('publicUrl')->willReturn('https://cdn.example.com/' . self::FILE_PATH);
101+
102+
$factory = $this->buildFactory('public', $adapter, $filesystem);
103+
$collection = $factory->createMediaObjects(new \stdClass());
104+
105+
$this->assertSame('https://cdn.example.com/' . self::FILE_PATH, $collection->get('file')[0]->contentUrl);
106+
}
107+
108+
public function test_falls_back_to_api_when_adapter_does_not_support_public_url_generator(): void
109+
{
110+
$filesystem = $this->createMock(Filesystem::class);
111+
$filesystem->expects($this->never())->method('publicUrl');
112+
113+
$factory = $this->buildFactory('public', new \stdClass(), $filesystem);
114+
$collection = $factory->createMediaObjects(new \stdClass());
115+
116+
$this->assertSame('http://example.com/api/download', $collection->get('file')[0]->contentUrl);
117+
}
118+
119+
public function test_uses_temporary_url_when_adapter_supports_temporary_url_generator(): void
120+
{
121+
$adapter = new class implements FlysystemTemporaryUrlGenerator {
122+
public function temporaryUrl(string $path, \DateTimeInterface $expiresAt, Config $config): string
123+
{
124+
return 'https://s3.example.com/' . $path . '?signed=abc';
125+
}
126+
};
127+
128+
$filesystem = $this->createMock(Filesystem::class);
129+
$filesystem->method('temporaryUrl')->willReturn('https://s3.example.com/' . self::FILE_PATH . '?signed=abc');
130+
131+
$factory = $this->buildFactory('temporary', $adapter, $filesystem);
132+
$collection = $factory->createMediaObjects(new \stdClass());
133+
134+
$this->assertStringStartsWith('https://s3.example.com/', $collection->get('file')[0]->contentUrl);
135+
$this->assertStringContainsString('signed=abc', $collection->get('file')[0]->contentUrl);
136+
}
137+
138+
public function test_falls_back_to_api_when_adapter_does_not_support_temporary_url_generator(): void
139+
{
140+
$filesystem = $this->createMock(Filesystem::class);
141+
$filesystem->expects($this->never())->method('temporaryUrl');
142+
143+
$factory = $this->buildFactory('temporary', new \stdClass(), $filesystem);
144+
$collection = $factory->createMediaObjects(new \stdClass());
145+
146+
$this->assertSame('http://example.com/api/download', $collection->get('file')[0]->contentUrl);
147+
}
148+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Silverback API Components Bundle Project
5+
*
6+
* (c) Daniel West <daniel@silverback.is>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Silverback\ApiComponentsBundle\Tests\Functional\TestBundle\Entity;
13+
14+
use ApiPlatform\Metadata\ApiResource;
15+
use Doctrine\ORM\Mapping as ORM;
16+
use Silverback\ApiComponentsBundle\Annotation as Silverback;
17+
use Silverback\ApiComponentsBundle\Entity\Utility\IdTrait;
18+
use Silverback\ApiComponentsBundle\Entity\Utility\UploadableTrait;
19+
use Symfony\Component\HttpFoundation\File\File;
20+
21+
#[Silverback\Uploadable]
22+
#[ApiResource]
23+
#[ORM\Entity]
24+
class DummyUploadablePublicUrl
25+
{
26+
use IdTrait;
27+
use UploadableTrait;
28+
29+
#[Silverback\UploadableField(adapter: 'public_url_local', urlGenerator: 'public')]
30+
public ?File $file = null;
31+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Silverback API Components Bundle Project
5+
*
6+
* (c) Daniel West <daniel@silverback.is>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Silverback\ApiComponentsBundle\Tests\Functional\TestBundle\Entity;
13+
14+
use ApiPlatform\Metadata\ApiResource;
15+
use Doctrine\ORM\Mapping as ORM;
16+
use Silverback\ApiComponentsBundle\Annotation as Silverback;
17+
use Silverback\ApiComponentsBundle\Entity\Utility\IdTrait;
18+
use Silverback\ApiComponentsBundle\Entity\Utility\UploadableTrait;
19+
use Symfony\Component\HttpFoundation\File\File;
20+
21+
#[Silverback\Uploadable]
22+
#[ApiResource]
23+
#[ORM\Entity]
24+
class DummyUploadableTemporaryUrl
25+
{
26+
use IdTrait;
27+
use UploadableTrait;
28+
29+
// Uses local adapter, which does not implement TemporaryUrlGenerator — verifies the fallback to the API download endpoint
30+
#[Silverback\UploadableField(adapter: 'local', urlGenerator: 'temporary')]
31+
public ?File $file = null;
32+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Silverback API Components Bundle Project
5+
*
6+
* (c) Daniel West <daniel@silverback.is>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Silverback\ApiComponentsBundle\Tests\Functional\TestBundle\Flysystem;
13+
14+
use League\Flysystem\Config;
15+
use League\Flysystem\Local\LocalFilesystemAdapter;
16+
use League\Flysystem\UrlGeneration\PublicUrlGenerator;
17+
18+
class PublicUrlLocalFilesystemAdapter extends LocalFilesystemAdapter implements PublicUrlGenerator
19+
{
20+
public function __construct(string $location, private readonly string $publicUrlBase)
21+
{
22+
parent::__construct($location);
23+
}
24+
25+
public function publicUrl(string $path, Config $config): string
26+
{
27+
return rtrim($this->publicUrlBase, '/') . '/' . ltrim($path, '/');
28+
}
29+
}

tests/Functional/app/config/services.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use Psr\Log\LoggerInterface;
1717
use Silverback\ApiComponentsBundle\Flysystem\FilesystemProvider;
1818
use Silverback\ApiComponentsBundle\Imagine\FlysystemCacheResolver;
19+
use Silverback\ApiComponentsBundle\Tests\Functional\TestBundle\Flysystem\PublicUrlLocalFilesystemAdapter;
1920
use Silverback\ApiComponentsBundle\Tests\Functional\TestBundle\Form\TestType;
2021
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
2122
use Symfony\Component\DependencyInjection\Reference;
@@ -52,6 +53,14 @@
5253
)
5354
->tag(FilesystemProvider::FILESYSTEM_ADAPTER_TAG, ['alias' => 'local']);
5455

56+
$services
57+
->set(PublicUrlLocalFilesystemAdapter::class)
58+
->args([
59+
'%kernel.project_dir%/public/uploads',
60+
'http://localhost/uploads',
61+
])
62+
->tag(FilesystemProvider::FILESYSTEM_ADAPTER_TAG, ['alias' => 'public_url_local']);
63+
5564
$services
5665
->set(FlysystemCacheResolver::class)
5766
->args([

0 commit comments

Comments
 (0)