Add new endpoint to retrieve user photos from Gravatar; implement parameter validation and comprehensive tests for various scenarios including output formats and error handling

This commit is contained in:
Eldad Fux
2025-10-22 10:06:45 +01:00
parent dfe87a0d37
commit 2fb560db37
5 changed files with 276 additions and 0 deletions
+111
View File
@@ -863,6 +863,117 @@ App::get('/v1/avatars/screenshots')
}
});
App::get('/v1/avatars/photos')
->desc('Get user photo from Gravatar')
->groups(['api', 'avatars'])
->label('scope', 'avatars.read')
->label('cache', true)
->label('cache.resourceType', 'avatar/photo')
->label('cache.resource', 'photo/{request.userId}/{request.width}/{request.height}/{request.quality}/{request.output}')
->label('sdk', new Method(
namespace: 'avatars',
group: null,
name: 'getPhoto',
description: '/docs/references/avatars/get-photo.md',
auth: [AuthType::SESSION, AuthType::KEY, AuthType::JWT],
type: MethodType::LOCATION,
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_NONE,
)
],
contentType: ContentType::IMAGE_PNG
))
->param('width', 100, new Range(0, 2000), 'Image width. Pass an integer between 0 to 2000. Defaults to 100.', true)
->param('height', 100, new Range(0, 2000), 'Image height. Pass an integer between 0 to 2000. Defaults to 100.', true)
->param('quality', -1, new Range(-1, 100), 'Image quality. Pass an integer between 0 to 100. Defaults to keep existing image quality.', true)
->param('output', '', new WhiteList(\array_keys(Config::getParam('storage-outputs')), true), 'Output format type (jpeg, jpg, png, gif and webp).', true)
->inject('response')
->inject('user')
->action(function (int $width, int $height, int $quality, string $output, Response $response, Document $user) {
if (!\extension_loaded('imagick')) {
throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Imagick extension is missing');
}
$email = $user->getAttribute('email', '');
if (empty($email)) {
throw new Exception(Exception::USER_NOT_FOUND, 'User email not found');
}
// Use the larger of width/height for Gravatar size parameter
$gravatarSize = \max($width, $height);
$gravatarSize = \max($gravatarSize, 80); // Minimum size for Gravatar
// Generate Gravatar URL
$emailHash = \md5(\strtolower(\trim($email)));
$gravatarUrl = 'https://www.gravatar.com/avatar/' . $emailHash . '?s=' . $gravatarSize . '&d=404';
$domain = new Domain(\parse_url($gravatarUrl, PHP_URL_HOST));
if (!$domain->isKnown()) {
throw new Exception(Exception::AVATAR_REMOTE_URL_FAILED);
}
$client = new Client();
try {
$res = $client
->setAllowRedirects(false)
->fetch($gravatarUrl);
} catch (\Throwable) {
throw new Exception(Exception::AVATAR_REMOTE_URL_FAILED);
}
$imageData = null;
$isGravatarImage = false;
if ($res->getStatusCode() === 200) {
try {
$imageData = $res->getBody();
$isGravatarImage = true;
} catch (\Throwable $exception) {
// Fall through to fallback image
}
}
// If no Gravatar image found, use a fallback image
if (!$isGravatarImage || empty($imageData)) {
$fileLogos = Config::getParam('storage-logos');
$fallbackPath = $fileLogos['default_image'];
if (!\is_readable($fallbackPath)) {
throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Fallback image not readable');
}
$imageData = \file_get_contents($fallbackPath);
}
try {
$image = new Image($imageData);
} catch (\Throwable $exception) {
throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unable to parse image');
}
$image->crop((int) $width, (int) $height);
// Determine output format - replicate storage preview logic
$outputs = Config::getParam('storage-outputs');
if (empty($output)) {
$output = 'png'; // Default to PNG
}
$data = $image->output($output, $quality);
unset($image);
$contentType = (\array_key_exists($output, $outputs)) ? $outputs[$output] : $outputs['png'];
$response
->addHeader('Cache-Control', 'private, max-age=2592000') // 30 days
->setContentType($contentType)
->file($data);
});
App::get('/v1/cards/cloud')
->desc('Get front Of Cloud Card')
->groups(['api', 'avatars'])
+5
View File
@@ -0,0 +1,5 @@
Use this endpoint to fetch a user's photo from Gravatar based on their email address. The API automatically generates the Gravatar URL using the user's email hash and provides standard image cropping options.
When width and height are specified, the image is resized accordingly. If both dimensions are 0, the API provides an image at original size. If dimensions are not specified, the default size is 100x100px.
This endpoint requires a valid user email address and will return a 404 if no Gravatar image is found for the user.
@@ -1190,4 +1190,87 @@ trait AvatarsBase
return [];
}
public function testGetPhoto(): array
{
/**
* Test for SUCCESS - Note: This test may fail if the test user doesn't have a Gravatar image
*/
$response = $this->client->call(Client::METHOD_GET, '/avatars/photos', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'width' => 200,
'height' => 200,
]);
// Gravatar returns 404 if no image is found for the email
if ($response['headers']['status-code'] === 404) {
$this->markTestSkipped('Test user does not have a Gravatar image');
}
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals('image/png', $response['headers']['content-type']);
$this->assertNotEmpty($response['body']);
// Test with different output format
$response = $this->client->call(Client::METHOD_GET, '/avatars/photos', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'width' => 100,
'height' => 100,
'quality' => 80,
'output' => 'jpeg',
]);
// Gravatar returns 404 if no image is found for the email
if ($response['headers']['status-code'] === 404) {
$this->markTestSkipped('Test user does not have a Gravatar image');
}
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertStringContainsString('image/', $response['headers']['content-type']);
$this->assertNotEmpty($response['body']);
/**
* Test for FAILURE - Invalid width parameter
*/
$response = $this->client->call(Client::METHOD_GET, '/avatars/photos', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'width' => 3000, // Too high (max 2000)
]);
$this->assertEquals(400, $response['headers']['status-code']);
/**
* Test for FAILURE - Invalid height parameter
*/
$response = $this->client->call(Client::METHOD_GET, '/avatars/photos', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'height' => 3000, // Too high (max 2000)
]);
$this->assertEquals(400, $response['headers']['status-code']);
/**
* Test for FAILURE - Invalid quality parameter
*/
$response = $this->client->call(Client::METHOD_GET, '/avatars/photos', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'quality' => 150, // Too high (max 100)
]);
$this->assertEquals(400, $response['headers']['status-code']);
/**
* Test for FAILURE - Invalid output parameter
*/
$response = $this->client->call(Client::METHOD_GET, '/avatars/photos', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
'output' => 'invalid-format', // Invalid format
]);
$this->assertEquals(400, $response['headers']['status-code']);
return [];
}
}
@@ -353,4 +353,74 @@ class AvatarsTest extends Scope
return $screenshot['body'];
}
public function testGetPhoto()
{
$projectId = $this->getProject()['$id'];
$query = $this->getQuery(self::GET_PHOTO);
$graphQLPayload = [
'query' => $query,
'variables' => [
'width' => 200,
'height' => 200,
],
];
$photo = $this->client->call(Client::METHOD_POST, '/graphql', \array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $this->getHeaders()), $graphQLPayload);
$this->assertEquals(200, $photo['headers']['status-code']);
$this->assertNotEmpty($photo['body']);
// Check if GraphQL schema includes the photos field
if (!str_contains($photo['headers']['content-type'], 'image/')) {
$this->assertArrayHasKey('errors', $photo['body']);
$this->assertNotEmpty($photo['body']['errors']);
$this->assertStringContainsString('Cannot query field "avatarsGetPhoto"', $photo['body']['errors'][0]['message']);
$this->markTestSkipped('GraphQL schema does not include avatarsGetPhoto field yet');
}
$this->assertStringContainsString('image/', $photo['headers']['content-type']);
return $photo['body'];
}
public function testGetPhotoWithOutput()
{
$projectId = $this->getProject()['$id'];
$query = $this->getQuery(self::GET_PHOTO);
$graphQLPayload = [
'query' => $query,
'variables' => [
'width' => 100,
'height' => 100,
'quality' => 80,
'output' => 'jpeg',
],
];
$photo = $this->client->call(Client::METHOD_POST, '/graphql', \array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $this->getHeaders()), $graphQLPayload);
$this->assertEquals(200, $photo['headers']['status-code']);
$this->assertNotEmpty($photo['body']);
// Check if GraphQL schema includes the photos field
if (!str_contains($photo['headers']['content-type'], 'image/')) {
$this->assertArrayHasKey('errors', $photo['body']);
$this->assertNotEmpty($photo['body']['errors']);
$this->assertStringContainsString('Cannot query field "avatarsGetPhoto"', $photo['body']['errors'][0]['message']);
$this->markTestSkipped('GraphQL schema does not include avatarsGetPhoto field yet');
}
$this->assertStringContainsString('image/', $photo['headers']['content-type']);
return $photo['body'];
}
}
+7
View File
@@ -289,6 +289,7 @@ trait Base
public const string GET_QRCODE = 'get_qrcode';
public const string GET_USER_INITIALS = 'get_user_initials';
public const string GET_SCREENSHOT = 'get_screenshot';
public const string GET_PHOTO = 'get_photo';
// Providers
public const string CREATE_MAILGUN_PROVIDER = 'create_mailgun_provider';
@@ -1786,6 +1787,12 @@ trait Base
status
}
}';
case self::GET_PHOTO:
return 'query getPhoto($width: Int, $height: Int, $quality: Int, $output: String) {
avatarsGetPhoto(width: $width, height: $height, quality: $quality, output: $output) {
status
}
}';
case self::GET_ACCOUNT:
return 'query getAccount {
accountGet {