Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
b37d638
update composer with new json schema
JonasVHG Jan 7, 2026
2cfb093
Merge branch 'master' into III-5017-image-upload-via-body
JonasVHG Jan 7, 2026
b16fd4c
update tests with correct content-type
JonasVHG Jan 7, 2026
892c320
split up UploadMediaRequestHandler depending on Content-type
JonasVHG Jan 7, 2026
9aae5a9
Add acceptance test
JonasVHG Jan 8, 2026
69a2413
bugfix
JonasVHG Jan 8, 2026
c68c31b
fix acceptance test
JonasVHG Jan 8, 2026
022acce
add uploadFromUrl to interface
JonasVHG Jan 8, 2026
3f6d23d
Add image-post to JsonSchemaLocator
JonasVHG Jan 8, 2026
7f66013
remove debug info
JonasVHG Jan 8, 2026
e3be2c8
refactor handleJsonBody()
JonasVHG Jan 8, 2026
8ca6771
linting
JonasVHG Jan 8, 2026
83380e9
Update src/Http/Media/UploadMediaRequestHandler.php
JonasVHG Jan 8, 2026
c7412d8
Update src/Media/ImageUploaderService.php
JonasVHG Jan 8, 2026
118de61
fix claude error
JonasVHG Jan 8, 2026
4fe8aef
fix errors
JonasVHG Jan 8, 2026
25e409a
Add ImageDownloaders
JonasVHG Jan 14, 2026
97aaae0
Add ImageDownloaders
JonasVHG Jan 14, 2026
9f3d84d
Add ImageDownloader to UploadMediaRequestHandler
JonasVHG Jan 14, 2026
ffc3ae0
undo changes in imageuploader
JonasVHG Jan 14, 2026
b682371
Add test
JonasVHG Jan 15, 2026
203ab69
refactor
JonasVHG Jan 15, 2026
f38808e
add filesize limit
JonasVHG Jan 15, 2026
506fb2a
add url validation
JonasVHG Jan 15, 2026
a5ede08
add timeout
JonasVHG Jan 15, 2026
ad1abe6
claude feedback
JonasVHG Jan 15, 2026
aed81e3
Add test
JonasVHG Jan 15, 2026
a508483
Add testing for mimeTypes
JonasVHG Jan 15, 2026
89f19ec
Add testing for mimeTypes
JonasVHG Jan 15, 2026
12e7d9e
remove debug info
JonasVHG Jan 15, 2026
80a3118
Add JsonBody test
JonasVHG Jan 15, 2026
5f211df
Add more tests
JonasVHG Jan 15, 2026
c4009ea
claude suggestion
JonasVHG Jan 15, 2026
0fd3926
Add acceptance test
JonasVHG Jan 15, 2026
d40ee66
Update tests/Media/ImageDownloaderServiceTest.php
JonasVHG Jan 15, 2026
d7fbd39
Allow local domains
JonasVHG Jan 16, 2026
e516e9b
add timeout via config
JonasVHG Jan 19, 2026
40a0f63
Add testfiles
JonasVHG Jan 19, 2026
c91a004
Add acceptance tests
JonasVHG Jan 19, 2026
c28ea64
Merge branch 'master' into III-5017-image-upload-via-body
JonasVHG Jan 19, 2026
25c3199
Add check & tests to for non 200 statuscodes
JonasVHG Jan 19, 2026
0b84472
don't allow unlimited size & provide 10M as default
JonasVHG Jan 23, 2026
ed3a413
Add acceptance test for 404 images
JonasVHG Jan 23, 2026
f157398
rename ImageDownloaderService to GuzzleImageDownloader
JonasVHG Jan 23, 2026
78ccf43
remove 1 stream
JonasVHG Jan 23, 2026
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
18 changes: 18 additions & 0 deletions app/Media/MediaServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use CultuurNet\UDB3\Media\ReadModel\ImageLDProjector;
use CultuurNet\UDB3\Media\Serialization\MediaObjectSerializer;
use CultuurNet\UDB3\Model\Serializer\ValueObject\MediaObject\ImageNormalizer;
use GuzzleHttp\Client;

final class MediaServiceProvider extends AbstractServiceProvider
{
Expand All @@ -26,6 +27,7 @@ protected function getProvidedServiceNames(): array
{
return [
'image_uploader',
ImageDownloader::class,
'media_object_store',
'media_object_repository',
'media_object_iri_generator',
Expand Down Expand Up @@ -58,6 +60,21 @@ function () use ($container) {
}
);

$container->addShared(
ImageDownloader::class,
fn () => new GuzzleImageDownloader(
new Client([
'timeout' => $container->get('config')['media']['timeout'] ?? 30,
'connect_timeout' => $container->get('config')['media']['connect_timeout'] ?? 10,
'allow_redirects' => [
'max' => 3,
'strict' => true,
],
]),
$container->get('config')['media']['file_size_limit'] ?? 1000000
)
);

$container->addShared(
'media_object_store',
function () use ($container) {
Expand Down Expand Up @@ -168,6 +185,7 @@ function () use ($container) {
function () use ($container) {
return new UploadMediaRequestHandler(
$container->get('image_uploader'),
$container->get(ImageDownloader::class),
$container->get('media_object_iri_generator')
);
}
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
"predis/predis": "~1.0",
"psr/http-server-middleware": "^1.0",
"psr/log": "^1.0",
"publiq/udb3-json-schemas": "dev-main",
"publiq/udb3-json-schemas": "dev-III-5017-docs",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Dependency Management: Using dev branch instead of stable release

The dependency points to a development branch (dev-III-5017-docs) instead of a tagged version. Before merging this PR, you should:

  1. Merge the changes in the udb3-json-schemas repository
  2. Create a tagged release (e.g., v1.2.3)
  3. Update this dependency to use the stable version

This ensures:

  • Reproducible builds
  • No unexpected changes when the branch is updated
  • Proper semantic versioning
Suggested change
"publiq/udb3-json-schemas": "dev-III-5017-docs",
"publiq/udb3-json-schemas": "^1.2.3",

(Replace with actual version number once released)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Dependency: Using dev branch instead of stable release

The dependency points to a development branch (dev-III-5017-docs) instead of a tagged version. Before merging this PR:

  1. Merge and release the changes in udb3-json-schemas repository
  2. Create a tagged release (e.g., v1.2.3)
  3. Update this dependency to the stable version

Using dev branches in production can cause:

  • Non-reproducible builds
  • Unexpected changes when the branch updates
  • Deployment issues
Suggested change
"publiq/udb3-json-schemas": "dev-III-5017-docs",
"publiq/udb3-json-schemas": "^1.2.3",

(Replace with the actual version number once released)

"ramsey/uuid": "^3.2.0",
"rase/socket.io-emitter": "0.6.1",
"sentry/sentry": "^3.6",
Expand Down
17 changes: 8 additions & 9 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

132 changes: 130 additions & 2 deletions features/image/create.feature
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Feature: Test the UDB3 image API
And I am authorized as JWT provider user "centraal_beheerder"
And I accept "application/json"

Scenario: Create image
Scenario: Create image via multiform
Given I set the form data properties to:
| description | logo |
| copyrightHolder | me |
Expand Down Expand Up @@ -50,7 +50,7 @@ Feature: Test the UDB3 image API
}
"""

Scenario: Create image without trailing slash in URL
Scenario: Create image via multiform without trailing slash in URL
Given I set the form data properties to:
| description | logo |
| copyrightHolder | me |
Expand Down Expand Up @@ -93,3 +93,131 @@ Feature: Test the UDB3 image API
"id": "%{image_id}"
}
"""

Scenario: Create image via json body
Given I send and accept "application/json"
And I set the JSON request payload to:
"""
{
"contentUrl": "http://io.uitdatabank.local/testfiles/publiq.png",
"description": "afbeelding via Json Body",
"copyrightHolder": "publiq",
"inLanguage": "nl"
}
"""
And I send a POST request to "/images/"
Then the response status should be "201"
And I keep the value of the JSON response at "@id" as "image_@id"
And I keep the value of the JSON response at "imageId" as "image_id"
When I send a GET request to "/images/%{image_id}"
Then the response status should be "200"
And the JSON response should be:
"""
{
"@id": "%{baseUrl}/images/%{image_id}",
"@type":"schema:ImageObject",
"contentUrl":"https://images.uitdatabank.dev/%{image_id}.png",
"thumbnailUrl":"https://images.uitdatabank.dev/%{image_id}.png",
"description":"afbeelding via Json Body",
"copyrightHolder":"publiq",
"inLanguage":"nl",
"id": "%{image_id}"
}
"""

Scenario: Create image with unknown file extension
Given I send and accept "application/json"
And I set the JSON request payload to:
"""
{
"contentUrl": "http://io.uitdatabank.local/testfiles/publiq",
"description": "afbeelding via Json Body",
"copyrightHolder": "publiq",
"inLanguage": "nl"
}
"""
And I send a POST request to "/images/"
Then the response status should be "201"
And I keep the value of the JSON response at "@id" as "image_@id"
And I keep the value of the JSON response at "imageId" as "image_id"
When I send a GET request to "/images/%{image_id}"
Then the response status should be "200"
And the JSON response should be:
"""
{
"@id": "%{baseUrl}/images/%{image_id}",
"@type":"schema:ImageObject",
"contentUrl":"https://images.uitdatabank.dev/%{image_id}.png",
"thumbnailUrl":"https://images.uitdatabank.dev/%{image_id}.png",
"description":"afbeelding via Json Body",
"copyrightHolder":"publiq",
"inLanguage":"nl",
"id": "%{image_id}"
}
"""

Scenario: check for non image types
Given I send and accept "application/json"
And I set the JSON request payload to:
"""
{
"contentUrl": "http://io.uitdatabank.local/testfiles/textfile",
"description": "afbeelding via Json Body",
"copyrightHolder": "publiq",
"inLanguage": "nl"
}
"""
And I send a POST request to "/images/"
Then the response status should be "400"
And the JSON response should be:
"""
{
"type":"https:\/\/api.publiq.be\/probs\/body\/file-invalid-type",
"title":"Invalid file type",
"status":400,
"detail":"The uploaded file has mime type \"text\/plain\" instead of image\/png,image\/jpeg,image\/gif"
}
"""

Scenario: It handles non existing urls
Given I send and accept "application/json"
And I set the JSON request payload to:
"""
{
"contentUrl": "http://io.uitdatabank.local/testfiles/thisDoesNotExist.png",
"description": "afbeelding via Json Body",
"copyrightHolder": "publiq",
"inLanguage": "nl"
}
"""
And I send a POST request to "/images/"
Then the response status should be "400"
And the JSON response should be:
"""
{
"type":"https:\/\/api.publiq.be\/probs\/body\/file-invalid-type",
"title":"Invalid file type",
"status":400,
"detail":"The file could not be downloaded correctly."
}
"""

Scenario: It handles missing data
Given I send and accept "application/json"
And I set the JSON request payload to:
"""
{
"contentUrl": "http://io.uitdatabank.local/testfiles/publiq.png"
}
"""
And I send a POST request to "/images/"
Then the response status should be "400"
And the JSON response should be:
"""
{
"type":"https:\/\/api.publiq.be\/probs\/body\/invalid-data",
"title":"Invalid body data",
"status":400,
"schemaErrors":[{"jsonPointer":"\/","error":"The required properties (description, copyrightHolder, inLanguage) are missing"}]
}
"""
67 changes: 58 additions & 9 deletions src/Http/Media/UploadMediaRequestHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,18 @@
namespace CultuurNet\UDB3\Http\Media;

use CultuurNet\UDB3\Http\ApiProblem\ApiProblem;
use CultuurNet\UDB3\Http\Request\Body\JsonSchemaLocator;
use CultuurNet\UDB3\Http\Request\Body\JsonSchemaValidatingRequestBodyParser;
use CultuurNet\UDB3\Http\Request\Body\RequestBodyParserFactory;
use CultuurNet\UDB3\Http\Response\JsonResponse;
use CultuurNet\UDB3\Iri\IriGeneratorInterface;
use CultuurNet\UDB3\Media\ImageDownloader;
use CultuurNet\UDB3\Media\ImageUploaderInterface;
use CultuurNet\UDB3\Media\Properties\Description;
use CultuurNet\UDB3\Model\ValueObject\Identity\Uuid;
use CultuurNet\UDB3\Model\ValueObject\MediaObject\CopyrightHolder;
use CultuurNet\UDB3\Model\ValueObject\Translation\Language;
use CultuurNet\UDB3\Model\ValueObject\Web\Url;
use InvalidArgumentException;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
Expand All @@ -20,15 +26,42 @@
final class UploadMediaRequestHandler implements RequestHandlerInterface
{
private ImageUploaderInterface $imageUploader;

private ImageDownloader $imageDownloader;
private IriGeneratorInterface $iriGenerator;

public function __construct(ImageUploaderInterface $imageUploader, IriGeneratorInterface $iriGenerator)
{
public function __construct(
ImageUploaderInterface $imageUploader,
ImageDownloader $imageDownloader,
IriGeneratorInterface $iriGenerator
) {
$this->imageUploader = $imageUploader;
$this->imageDownloader = $imageDownloader;
$this->iriGenerator = $iriGenerator;
}

public function handle(ServerRequestInterface $request): ResponseInterface
{
$contentTypeHeaders = $request->getHeader('Content-Type');
$isMultipart = !empty($contentTypeHeaders)
&& isset($contentTypeHeaders[0])
&& str_contains($contentTypeHeaders[0], 'multipart/form-data');
if ($isMultipart) {
$imageId = $this->handleFormData($request);
} else {
$imageId = $this->handleJsonBody($request);
}

return new JsonResponse(
[
'@id' => $this->iriGenerator->iri($imageId->toString()),
'imageId' => $imageId->toString(),
],
201
);
}

private function handleFormData(ServerRequestInterface $request): Uuid
{
$uploadedFiles = $request->getUploadedFiles();
if (!isset($uploadedFiles['file']) || !$uploadedFiles['file'] instanceof UploadedFileInterface) {
Expand Down Expand Up @@ -69,19 +102,35 @@ public function handle(ServerRequestInterface $request): ResponseInterface
throw ApiProblem::bodyInvalidDataWithDetail('Form data field "language" is must be exactly 2 lowercase letters long (for example "nl").');
}

$imageId = $this->imageUploader->upload(
return $this->imageUploader->upload(
$uploadedFile,
new Description($description),
$copyrightHolder,
$language
);
}

private function handleJsonBody(ServerRequestInterface $request): Uuid
{
$parser = RequestBodyParserFactory::createBaseParser(
new JsonSchemaValidatingRequestBodyParser(JsonSchemaLocator::IMAGE_POST)
);

return new JsonResponse(
[
'@id' => $this->iriGenerator->iri($imageId->toString()),
'imageId' => $imageId->toString(),
],
201
/** @var \stdClass $data */
$data = $parser->parse($request)->getParsedBody();

$contentUrl = new Url($data->contentUrl);
$description = new Description($data->description);
$copyrightHolder = new CopyrightHolder($data->copyrightHolder);
$language = new Language($data->inLanguage);

$uploadedFile = $this->imageDownloader->download($contentUrl);

return $this->imageUploader->upload(
$uploadedFile,
$description,
$copyrightHolder,
$language
);
}
}
2 changes: 2 additions & 0 deletions src/Http/Request/Body/JsonSchemaLocator.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ final class JsonSchemaLocator

public const NEWS_ARTICLE_POST = 'newsArticle-post.json';

public const IMAGE_POST = 'image-post.json';

public static function setSchemaDirectory(string $schemaDirectory): void
{
if (!is_dir($schemaDirectory)) {
Expand Down
Loading