Skip to content

Commit

Permalink
[BUGFIX] Correct API Responses (#474)
Browse files Browse the repository at this point in the history
  • Loading branch information
benjaminkott authored Dec 2, 2024
1 parent 124f6dd commit 01e6a12
Show file tree
Hide file tree
Showing 12 changed files with 714 additions and 170 deletions.
3 changes: 3 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,9 @@
"sort-packages": true
},
"extra": {
"runtime": {
"use_putenv": true
},
"bamarni-bin": {
"bin-links": true,
"forward-command": false,
Expand Down
319 changes: 161 additions & 158 deletions composer.lock

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions config/packages/framework.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ framework:
php_errors:
log: true

serializer:
name_converter: 'serializer.name_converter.camel_case_to_snake_case'
default_context:
skip_null_values: true

when@test:
framework:
test: true
Expand Down
5 changes: 5 additions & 0 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,8 @@ services:
App\Service\CacheWarmupService:
arguments:
$baseUrl: '%env(BASE_URL)%'

serializer.normalizer.json_serializable:
class: Symfony\Component\Serializer\Normalizer\JsonSerializableNormalizer
tags:
- { name: 'serializer.normalizer', priority: -2048 }
15 changes: 8 additions & 7 deletions src/Entity/MajorVersion.php
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,13 @@ public function setReleases(Collection $releases): void
*/
public function getReleases(): Collection
{
return $this->releases;
$sorted = $this->releases->toArray();
usort($sorted, function ($a, $b) {
return version_compare($a->getVersion(), $b->getVersion());
});
$sorted = array_reverse($sorted);

return new ArrayCollection($sorted);
}

public function setTitle(string $title): void
Expand Down Expand Up @@ -376,14 +382,9 @@ public function jsonSerialize(): array
$releaseData[$release->getVersion()] = $release;
}

uksort(
$releaseData,
static fn(string $a, string $b): int => version_compare($a, $b)
);
$desc = array_reverse($releaseData);
$latest = $this->getLatestRelease();
return [
'releases' => $desc,
'releases' => $releaseData,
'latest' => $latest !== null ? $latest->getVersion() : '',
'stable' => $latest !== null ? $latest->getVersion() : '',
'active' => $this->isActive(),
Expand Down
11 changes: 6 additions & 5 deletions src/Entity/Release.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@
use App\Repository\ReleaseRepository;
use Doctrine\ORM\Mapping as ORM;
use OpenApi\Attributes as OA;
use Symfony\Component\Serializer\Attribute\Context;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
use Symfony\Component\Validator\Constraints as Assert;

#[OA\Schema(description: 'TYPO3 release', title: 'Release')]
Expand All @@ -39,17 +41,16 @@
class Release implements \JsonSerializable, \Stringable
{
#[OA\Property(example: '8.7.12')]
#[Assert\Regex(
'/^(\d+\.\d+\.\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/'
)]
#[Assert\Regex('/^(\d+\.\d+\.\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/')]
#[ORM\Id]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING)]
#[Groups(['content', 'data'])]
private string $version;

#[OA\Property(example: '2017-12-12T16:48:22+00:00')]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_MUTABLE)]
#[Groups(['data', 'content'])]
#[Groups(['content', 'data'])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d\\TH:i:sP'])]
private \DateTimeInterface $date;

#[Assert\Choice(callback: [ReleaseTypeEnum::class, 'getAvailableOptions'])]
Expand All @@ -60,7 +61,7 @@ class Release implements \JsonSerializable, \Stringable
#[OA\Property(example: true)]
#[Assert\Type('boolean')]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::BOOLEAN, options: ['default' => 0])]
#[Groups(['data', 'content'])]
#[Groups(['content', 'data'])]
private bool $elts = false;

#[Assert\Valid]
Expand Down
95 changes: 95 additions & 0 deletions tests/Functional/Controller/Api/ApiCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -98,4 +98,99 @@ protected function createRequirementFromJson(string $filePath, string $majorVers

return $this->client->getResponse();
}

/**
* @param array<int|string, mixed> $expectedStructure
* @param array<int|string, mixed> $actualArray
*/
protected function assertArrayStructure(array $expectedStructure, array $actualArray): void
{
// If the expected structure is an array (root level as list)
if (isset($expectedStructure[0])) {
self::assertIsArray($actualArray);

// Validate each item in the list
foreach ($actualArray as $item) {
self::assertIsArray($item);
/** @var array<int|string, array<int|string, mixed>> $expectedStructure */
/** @var array<int|string, mixed> $item */
$this->assertArrayStructure($expectedStructure[0], $item);
}
} else {
// Validate each key in the structure
foreach ($expectedStructure as $key => $value) {
$isOptional = is_string($key) && str_starts_with($key, '?');
$actualKey = $isOptional ? ltrim($key, '?') : $key;

if (array_key_exists($actualKey, $actualArray)) {
// If the key exists, validate its structure or type
if (is_array($value)) {
if ($this->isListStructure($value)) {
// Validate a list of items
self::assertIsArray($actualArray[$actualKey]);
foreach ($actualArray[$actualKey] as $item) {
/** @var array<int|string, array<int|string, mixed>> $value */
/** @var array<int|string, mixed> $item */
$this->assertArrayStructure($value[0], $item);
}
} else {
// Validate a single nested structure
/** @var array<int|string, mixed> $value */
/** @var array<int|string, array<int|string, mixed>> $actualArray */
$this->assertArrayStructure($value, $actualArray[$actualKey]);
}
} else {
/** @var string $value */
$this->assertIsType($value, $actualArray[$actualKey], "Key '$actualKey' does not match the expected type.");
}
} elseif (!$isOptional) {
// If the key is not optional, it must exist
self::fail("Missing required key: $actualKey");
}
}
}
}

protected function assertIsType(string $type, mixed $value, string $message): void
{
switch ($type) {
case 'string':
self::assertIsString($value, $message);
break;
case 'boolean':
self::assertIsBool($value, $message);
break;
case 'integer':
self::assertIsInt($value, $message);
break;
case 'float':
self::assertIsFloat($value, $message); // New check for float
break;
case 'array':
self::assertIsArray($value, $message);
break;
case 'datetime':
self::assertIsString($value, $message);
self::assertValidDateTime($value, $message);
break;
default:
self::fail("Unsupported type: $type");
}
}

protected function assertValidDateTime(string $value, string $message): void
{
// ISO 8601 datetime regex
$pattern = '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(\+\d{2}:\d{2}|Z)$/';
self::assertMatchesRegularExpression($pattern, $value, $message);
}

/**
* @param array<int|string, mixed> $structure
*/
protected function isListStructure(array $structure): bool
{
// Determines if the given structure is a list of items (e.g., [ { ... } ])
return count($structure) === 1 && array_keys($structure) === [0] && is_array($structure[0]);
}
}
194 changes: 194 additions & 0 deletions tests/Functional/Controller/Api/MajorVersion/ReleaseControllerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
<?php

declare(strict_types=1);

/*
* This file is part of the package t3o/get.typo3.org.
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* For the full copyright and license information, please read the
* LICENSE file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/

namespace App\Tests\Functional\Controller\Api\MajorVersion;

use App\DataFixtures\MajorVersionFixtures;
use App\DataFixtures\ReleaseFixtures;
use App\DataFixtures\RequirementFixtures;
use App\Tests\Functional\Controller\Api\ApiCase;

class ReleaseControllerTest extends ApiCase
{
/**
* @test
*/
public function getReleasesByMajorVersionStructureTest(): void
{
$this->addFixture(new MajorVersionFixtures());
$this->addFixture(new ReleaseFixtures());
$this->addFixture(new RequirementFixtures());
$this->executeFixtures();

$this->client->request('GET', '/api/v1/major/10/release/');

$response = $this->client->getResponse();
$responseContent = json_decode((string)$response->getContent(), true, 512, JSON_THROW_ON_ERROR);

self::assertIsArray($responseContent);
$this->assertArrayStructure(
[
[
'version' => 'string',
'date' => 'datetime',
'type' => 'string',
'elts' => 'boolean',
'tar_package' => [
'?md5sum' => 'string',
'?sha1sum' => 'string',
'?sha256sum' => 'string',
],
'zip_package' => [
'?md5sum' => 'string',
'?sha1sum' => 'string',
'?sha256sum' => 'string',
],
],
],
$responseContent
);
}

/**
* @test
*/
public function getLatestReleaseByMajorVersionStructureTest(): void
{
$this->addFixture(new MajorVersionFixtures());
$this->addFixture(new ReleaseFixtures());
$this->addFixture(new RequirementFixtures());
$this->executeFixtures();

$this->client->request('GET', '/api/v1/major/10/release/latest');

$response = $this->client->getResponse();
$responseContent = json_decode((string)$response->getContent(), true, 512, JSON_THROW_ON_ERROR);

self::assertIsArray($responseContent);
$this->assertArrayStructure(
[
'version' => 'string',
'date' => 'datetime',
'type' => 'string',
'elts' => 'boolean',
'tar_package' => [
'?md5sum' => 'string',
'?sha1sum' => 'string',
'?sha256sum' => 'string',
],
'zip_package' => [
'?md5sum' => 'string',
'?sha1sum' => 'string',
'?sha256sum' => 'string',
],
],
$responseContent
);
}

/**
* @test
*/
public function getLatestSecurityReleaseByMajorVersionStructureTest(): void
{
$this->addFixture(new MajorVersionFixtures());
$this->addFixture(new ReleaseFixtures());
$this->addFixture(new RequirementFixtures());
$this->executeFixtures();

$this->client->request('GET', '/api/v1/major/10/release/latest/security');

$response = $this->client->getResponse();
$responseContent = json_decode((string)$response->getContent(), true, 512, JSON_THROW_ON_ERROR);

self::assertIsArray($responseContent);
$this->assertArrayStructure(
[
'version' => 'string',
'date' => 'datetime',
'type' => 'string',
'elts' => 'boolean',
'tar_package' => [
'?md5sum' => 'string',
'?sha1sum' => 'string',
'?sha256sum' => 'string',
],
'zip_package' => [
'?md5sum' => 'string',
'?sha1sum' => 'string',
'?sha256sum' => 'string',
],
],
$responseContent
);
}

/**
* @test
*/
public function getLatestReleaseContentByMajorVersionStructureTest(): void
{
$this->addFixture(new MajorVersionFixtures());
$this->addFixture(new ReleaseFixtures());
$this->addFixture(new RequirementFixtures());
$this->executeFixtures();

$this->client->request('GET', '/api/v1/major/10/release/latest/content');

$response = $this->client->getResponse();
$responseContent = json_decode((string)$response->getContent(), true, 512, JSON_THROW_ON_ERROR);

self::assertIsArray($responseContent);
$this->assertArrayStructure(
[
'version' => 'string',
'date' => 'datetime',
'elts' => 'boolean',
'release_notes' => [
'news_link' => 'string',
'news' => 'string',
'upgrading_instructions' => 'string',
'changes' => 'string',
'?legacy_content' => 'string',
],
'?checksums' => [
'tar' => [
'?sha1' => 'string',
'?md5' => 'string',
'?sha256' => 'string',
],
'zip' => [
'?sha1' => 'string',
'?md5' => 'string',
'?sha256' => 'string',
],
],
'?urls' => [
'tar' => 'string',
'zip' => 'string',
],
],
$responseContent
);
}
}
Loading

0 comments on commit 01e6a12

Please sign in to comment.