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
183 changes: 183 additions & 0 deletions Classes/Annotation/ApiCacheRoundDatetime.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
<?php

namespace Xima\T3ApiCache\Annotation;

/**
* @Annotation
* @Target({"CLASS"})
*/
class ApiCacheRoundDatetime
{
/**
* Query parameter name to apply rounding to. Matches the parameter and all its filter
* variants (e.g. "date" resolves to "date", "date[lt]", "date[gte]", etc.).
*/
protected string $parameterName;

/**
* Rounding precision: "minute", "hour", "day", "year"
*/
protected string $precision = 'hour';

/**
* Rounding direction: "floor" (round down) or "ceil" (round up)
*/
protected string $direction = 'floor';

private const ALLOWED_PRECISIONS = ['minute', 'hour', 'day', 'year'];
private const ALLOWED_DIRECTIONS = ['floor', 'ceil'];

/**
* @param array<string, mixed> $options
*/
public function __construct(array $options = [])
{
if (!isset($options['parameterName']) && !isset($options['value'])) {
throw new \InvalidArgumentException('The "parameterName" option is required for @ApiCacheRoundDatetime.');
}
$this->parameterName = $options['parameterName'] ?? $options['value'];

if (isset($options['precision'])) {
if (!in_array($options['precision'], self::ALLOWED_PRECISIONS, true)) {
throw new \InvalidArgumentException(
sprintf(
'Invalid precision "%s". Allowed values: %s',
$options['precision'],
implode(', ', self::ALLOWED_PRECISIONS)
)
);
}
$this->precision = $options['precision'];
}
if (isset($options['direction'])) {
if (!in_array($options['direction'], self::ALLOWED_DIRECTIONS, true)) {
throw new \InvalidArgumentException(
sprintf(
'Invalid direction "%s". Allowed values: %s',
$options['direction'],
implode(', ', self::ALLOWED_DIRECTIONS)
)
);
}
$this->direction = $options['direction'];
}
}

public function getPrecision(): string
{
return $this->precision;
}

public function getDirection(): string
{
return $this->direction;
}

public function getParameterName(): string
{
return $this->parameterName;
}

/**
* Round a datetime string value according to the given precision and direction.
*
* Supports Unix timestamps and common datetime formats (ISO 8601, date-only, etc.).
* Returns the rounded value in the same format as the input.
*/
public static function roundDatetime(string $value, string $precision, string $direction = 'floor'): string
{
$isTimestamp = ctype_digit($value);

if ($isTimestamp) {
$dateTime = new \DateTimeImmutable('@' . $value);
} else {
try {
$dateTime = new \DateTimeImmutable($value);
} catch (\Exception | \Error) {
return $value;
}
}

$rounded = self::applyRounding($dateTime, $precision, $direction);

if ($isTimestamp) {
return (string)$rounded->getTimestamp();
}

// Detect date-only format (e.g. "2025-03-26")
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
return $rounded->format('Y-m-d');
}

return $rounded->format('c');
}

private static function applyRounding(\DateTimeImmutable $dateTime, string $precision, string $direction): \DateTimeImmutable
{
$isFloor = $direction === 'floor';

return match ($precision) {
'minute' => self::roundToMinute($dateTime, $isFloor),
'hour' => self::roundToHour($dateTime, $isFloor),
'day' => self::roundToDay($dateTime, $isFloor),
'year' => self::roundToYear($dateTime, $isFloor),
default => $dateTime,
};
}

private static function roundToMinute(\DateTimeImmutable $dateTime, bool $isFloor): \DateTimeImmutable
{
$floored = $dateTime->setTime(
(int)$dateTime->format('H'),
(int)$dateTime->format('i'),
0
);

if ($isFloor || $floored->getTimestamp() === $dateTime->getTimestamp()) {
return $floored;
}

return $floored->modify('+1 minute');
}

private static function roundToHour(\DateTimeImmutable $dateTime, bool $isFloor): \DateTimeImmutable
{
$floored = $dateTime->setTime(
(int)$dateTime->format('H'),
0,
0
);

if ($isFloor || $floored->getTimestamp() === $dateTime->getTimestamp()) {
return $floored;
}

return $floored->modify('+1 hour');
}

private static function roundToDay(\DateTimeImmutable $dateTime, bool $isFloor): \DateTimeImmutable
{
$floored = $dateTime->setTime(0, 0, 0);

if ($isFloor || $floored->getTimestamp() === $dateTime->getTimestamp()) {
return $floored;
}

return $floored->modify('+1 day');
}

private static function roundToYear(\DateTimeImmutable $dateTime, bool $isFloor): \DateTimeImmutable
{
$floored = $dateTime->setDate(
(int)$dateTime->format('Y'),
1,
1
)->setTime(0, 0, 0);

if ($isFloor || $floored->getTimestamp() === $dateTime->getTimestamp()) {
return $floored;
}

return $floored->modify('+1 year');
}
}
58 changes: 56 additions & 2 deletions Classes/Reflection/ResourceReflectionService.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,17 @@
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Persistence\Generic\Mapper\DataMapper;
use Xima\T3ApiCache\Annotation\ApiCache;
use Xima\T3ApiCache\Annotation\ApiCacheRoundDatetime;

class ResourceReflectionService
{
protected ?ApiCache $apiCacheAnnotation = null;

/**
* @var ApiCacheRoundDatetime[]
*/
protected array $apiCacheRoundDatetimeAnnotations = [];

private ?ApiResource $apiResource = null;

private string $cacheKey = '';
Expand All @@ -38,11 +44,14 @@ private function setApiCacheAnnotation(): void
$annotationReader = GeneralUtility::makeInstance(AnnotationReader::class);
/** @var ClassString $entityClass */
$entityClass = $this->apiResource->getEntity();
$annotations = $annotationReader->getClassAnnotations(new \ReflectionClass($entityClass));
$reflectionClass = new \ReflectionClass($entityClass);

$annotations = $annotationReader->getClassAnnotations($reflectionClass);
foreach ($annotations as $annotation) {
if ($annotation instanceof ApiCache) {
$this->apiCacheAnnotation = $annotation;
return;
} elseif ($annotation instanceof ApiCacheRoundDatetime) {
$this->apiCacheRoundDatetimeAnnotations[] = $annotation;
}
}
}
Expand Down Expand Up @@ -101,9 +110,54 @@ protected function setCacheKey(): void
return;
}

$validRequestParams = $this->roundDatetimeParameters($validRequestParams);

$this->cacheKey = md5($this->request->getUri()->getPath() . '?' . http_build_query($validRequestParams));
}

/**
* Round datetime parameters according to @ApiCacheRoundDatetime annotations.
*
* Handles both direct parameter values (e.g. "date=123") and filter sub-parameters
* (e.g. "date[lt]=123", "date[gte]=456").
*
* @param array<string, mixed> $params
* @return array<string, mixed>
*/
protected function roundDatetimeParameters(array $params): array
{
if (empty($this->apiCacheRoundDatetimeAnnotations)) {
return $params;
}

foreach ($this->apiCacheRoundDatetimeAnnotations as $annotation) {
$parameterName = $annotation->getParameterName();
if (!isset($params[$parameterName])) {
continue;
}

if (is_string($params[$parameterName])) {
$params[$parameterName] = ApiCacheRoundDatetime::roundDatetime(
$params[$parameterName],
$annotation->getPrecision(),
$annotation->getDirection()
);
} elseif (is_array($params[$parameterName])) {
foreach ($params[$parameterName] as $filterKey => $filterValue) {
if (is_string($filterValue)) {
$params[$parameterName][$filterKey] = ApiCacheRoundDatetime::roundDatetime(
$filterValue,
$annotation->getPrecision(),
$annotation->getDirection()
);
}
}
}
}

return $params;
}

public function getTableName(): string
{
$dataMapper = GeneralUtility::makeInstance(DataMapper::class);
Expand Down
77 changes: 76 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ class News extends AbstractEntity

## Configuration

There a two configuration options available:
There are multiple configuration options available:

### `parametersToIgnore`

Expand Down Expand Up @@ -77,3 +77,78 @@ class ExampleResource extends AbstractEntity
{
}
```

### `@ApiCacheRoundDatetime`

When using datetime filters, clients often request the API with the current timestamp. Since the timestamp is always different,
no cache hits occur. The `@ApiCacheRoundDatetime` annotation can be placed on the **class** to round the corresponding
datetime filter parameter value to a configurable precision before the cache key is generated. This ensures that requests within
the same time window produce the same cache key, significantly improving cache hit rates.

Multiple `@ApiCacheRoundDatetime` annotations can be used on the same class — one per parameter.

The `parameterName` specifies the base query parameter name (e.g. `"date"`), and it automatically applies to all filter
variants of that parameter (e.g. `date=123`, `date[lt]=...`, `date[gte]=...`).

The annotation accepts the following options:

- `parameterName` (required): The query parameter name to apply rounding to.
- `precision`: The rounding precision. Supported values are `minute`, `hour`, `day`, and `year`. Default is `hour`.
- `direction` (optional): The rounding direction. Use `floor` (default) to round down or `ceil` to round up.

**Example: Round a datetime filter to the nearest hour (floor)**

```php
<?php

use SourceBroker\T3api\Annotation\ApiFilter;
use SourceBroker\T3api\Filter\OrderFilter;
use Xima\T3ApiCache\Annotation\ApiCache;
use Xima\T3ApiCache\Annotation\ApiCacheRoundDatetime;

/**
* @ApiResource(
* collectionOperations={
* "get": {
* "path": "/event"
* }
* }
* )
* @ApiFilter(OrderFilter::class, properties={"date"}, arguments={"parameterName": "date"})
* @ApiCache
* @ApiCacheRoundDatetime(parameterName="date", precision="hour")
*/
class Event extends AbstractEntity
{
protected \DateTime $date;
}
```

In this example, a request with `?date=2025-03-26T09:47:12+00:00` and another with `?date=2025-03-26T09:12:45+00:00`
will both be rounded to `2025-03-26T09:00:00+00:00`, resulting in the same cache key.

Filter variants like `?date[gte]=2025-03-26T09:47:12+00:00` are also automatically rounded.

**Example: Multiple datetime parameters with different precisions**

```php
<?php

use Xima\T3ApiCache\Annotation\ApiCache;
use Xima\T3ApiCache\Annotation\ApiCacheRoundDatetime;

/**
* ...
* @ApiCache
* @ApiCacheRoundDatetime(parameterName="startDate", precision="day")
* @ApiCacheRoundDatetime(parameterName="endDate", precision="hour", direction="ceil")
*/
class Event extends AbstractEntity
{
protected \DateTime $startDate;
protected \DateTime $endDate;
}
```

The annotation supports Unix timestamps, ISO 8601 dates, and date-only strings (e.g. `2025-03-26`).
The rounded value is returned in the same format as the input.
3 changes: 3 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,8 @@
"psr-4": {
"Xima\\T3ApiCache\\": "Classes"
}
},
"config": {
"lock": false
}
}