diff --git a/.github/workflows/code_checks.yaml b/.github/workflows/code_checks.yaml
index 0c02c69..8ad9158 100644
--- a/.github/workflows/code_checks.yaml
+++ b/.github/workflows/code_checks.yaml
@@ -56,7 +56,7 @@ jobs:
- name: PHPStan
continue-on-error: ${{ !matrix.is-current }}
- run: vendor/bin/phpstan analyse src tests --level 9
+ run: vendor/bin/phpstan analyse
- name: Execute tests
env:
diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php
index efa0cc1..df668df 100644
--- a/.php-cs-fixer.php
+++ b/.php-cs-fixer.php
@@ -16,6 +16,7 @@
'php_unit_internal_class' => false,
'php_unit_test_class_requires_covers' => false,
+ 'method_chaining_indentation' => false,
])
->setFinder(
PhpCsFixer\Finder::create()
diff --git a/README.md b/README.md
index c8b4651..03dd58e 100755
--- a/README.md
+++ b/README.md
@@ -17,47 +17,22 @@ Documentation
The source of the documentation is stored in the `docs/` folder in this bundle
-- [Link the calendar to a CRUD and allow create, update, delete & show events](docs/doctrine-crud.md)
-- [Webpack Encore and fullcalendar.js](docs/es6-encore.md)
-- [Multi calendar](docs/multi-calendar.md)
-- [Migrating to v8.1](docs/upgrade-to-81.md)
-
### Installation
-1. [Download CalendarBundle using composer](#1-download-calendarbundle-using-composer)
-2. [Create the subscriber](#2-create-the-subscriber)
-3. [Add styles and scripts in your template](#3-add-styles-and-scripts-in-your-template)
-4. [Security Configuration](#4-security-configuration)
-
-#### 1. Download CalendarBundle using composer
-
```sh
composer require tattali/calendar-bundle
```
-The recipe will import the routes for you
-Check the existence of the file `config/routes/calendar.yaml` or create it
+Import the routes:
```yaml
# config/routes/calendar.yaml
calendar:
resource: '@CalendarBundle/config/routing.yaml'
```
-#### 2. Create the subscriber
-You need to create a subscriber class to load your data into the calendar.
-
-This subscriber must be registered **only if autoconfigure is false**.
-```yaml
-# config/services.yaml
-services:
- # ...
-
- App\EventSubscriber\CalendarSubscriber:
-```
-
-Then, create the subscriber class to fill the calendar
+### Quick Start
-See the [doctrine subscriber example](docs/doctrine-crud.md#full-subscriber)
+Create a subscriber to populate calendar data:
```php
// src/EventSubscriber/CalendarSubscriber.php
@@ -71,14 +46,14 @@ use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class CalendarSubscriber implements EventSubscriberInterface
{
- public static function getSubscribedEvents()
+ public static function getSubscribedEvents(): array
{
return [
SetDataEvent::class => 'onCalendarSetData',
];
}
- public function onCalendarSetData(SetDataEvent $setDataEvent)
+ public function onCalendarSetData(SetDataEvent $setDataEvent): void
{
$start = $setDataEvent->getStart();
$end = $setDataEvent->getEnd();
@@ -87,9 +62,9 @@ class CalendarSubscriber implements EventSubscriberInterface
// You may want to make a custom query from your database to fill the calendar
$setDataEvent->addEvent(new Event(
- 'Event 1',
+ 'Event title',
new \DateTime('Tuesday this week'),
- new \DateTime('Wednesdays this week')
+ new \DateTime('Wednesday this week')
));
// If the end date is null or not defined, it creates a all day event
@@ -101,131 +76,46 @@ class CalendarSubscriber implements EventSubscriberInterface
}
```
-#### 3. Add styles and scripts in your template
-
-Include the html template were you want to display the calendar:
-
+Add the calendar to your template:
```twig
{% block body %}
{% endblock %}
-```
-
-Add styles and js. Click [here](https://fullcalendar.io/download) to see other css and js download methods, you can also found the [plugins list](https://fullcalendar.io/docs/plugin-index)
-```twig
{% block javascripts %}
+
{% endblock %}
```
-#### 4. Security Configuration
-
-If your application uses Symfony's security firewall, ensure the `/fc-load-events` route is accessible. Add it to your `access_control` configuration:
-
-```yaml
-# config/packages/security.yaml
-security:
- access_control:
- - { path: ^/fc-load-events, roles: PUBLIC_ACCESS }
- # Or restrict to authenticated users:
- # - { path: ^/fc-load-events, roles: ROLE_USER }
-```
-
-You can also define your own route pointing directly to the bundle's controller by replacing `config/routes/calendar.yaml`:
-
-```yaml
-# config/routes/calendar.yaml
-fc_load_events:
- path: /fc-load-events
- controller: CalendarBundle\Controller\CalendarController::load
-```
-
-```yaml
-# config/packages/security.yaml
-security:
- access_control:
- - { path: ^/fc-load-events, roles: ROLE_USER }
-```
-
-Alternatively, you can create your own controller that wraps the bundle's controller and add security attributes:
-
-```php
-// src/Controller/CalendarController.php
-namespace App\Controller;
-
-use CalendarBundle\Controller\CalendarController as BaseCalendarController;
-use Symfony\Component\HttpFoundation\JsonResponse;
-use Symfony\Component\HttpFoundation\Request;
-use Symfony\Component\Routing\Attribute\Route;
-use Symfony\Component\Security\Http\Attribute\IsGranted;
-
-class CalendarController
-{
- public function __construct(
- private BaseCalendarController $calendarController,
- ) {}
-
- #[Route('/fc-load-events', name: 'fc_load_events', methods: ['POST'])]
- #[IsGranted('ROLE_USER')]
- public function load(Request $request): JsonResponse
- {
- return $this->calendarController->load($request);
- }
-}
-```
-
-Then disable the bundle's route by removing or commenting out `config/routes/calendar.yaml`.
-
-### Basic functionalities
-
-You will probably want to customize the Calendar javascript to fit the needs of your application.
-To do this, you can copy the following settings and modify them by consulting the [fullcalendar.js documentation](https://fullcalendar.io/docs).
-```js
-document.addEventListener('DOMContentLoaded', () => {
- const calendarEl = document.getElementById('calendar-holder');
-
- const calendar = new FullCalendar.Calendar(calendarEl, {
- defaultView: 'dayGridMonth',
- editable: true,
- eventSources: [
- {
- url: '/fc-load-events',
- method: 'POST',
- extraParams: {
- filters: JSON.stringify({})
- },
- failure: () => {
- // alert('There was an error while fetching FullCalendar!');
- },
- },
- ],
- headerToolbar: {
- start: 'prev,next today',
- center: 'title',
- end: 'dayGridMonth,timeGridWeek,timeGridDay'
- },
- timeZone: 'UTC',
- });
-
- calendar.render();
-});
-```
+### Documentation
-You can use [Plugins](https://fullcalendar.io/docs/plugin-index) to reduce loadtime.
+- [Configuration](docs/configuration.md) - Caching, JSON depth limit, routing
+- [Doctrine CRUD](docs/doctrine-crud.md) - Create, update, delete events with Doctrine
+- [Webpack Encore](docs/es6-encore.md) - ES6 module setup
+- [Multi-calendar](docs/multi-calendar.md) - Multiple calendars on one page
+- [Security](docs/configuration.md#security) - Securing the endpoint
-## Troubleshoot AJAX requests
+#### Upgrade Guides
-* To debug AJAX requests, show the Network monitor, then reload the page. Finally click on `fc-load-events` and select the `Response` or `Preview` tab
- - Firefox: `Ctrl + Shift + E` ( `Command + Option + E` on Mac )
- - Chrome: `Ctrl + Shift + I` ( `Command + Option + I` on Mac )
+- [Migrating to v8.2](docs/upgrade-to-82.md)
+- [Migrating to v8.1](docs/upgrade-to-81.md)
-Contribute and feedback
------------------------
+### Troubleshooting
-Any feedback and contribution will be very appreciated.
+Debug AJAX requests using browser Network monitor (`Ctrl+Shift+E` Firefox, `Ctrl+Shift+I` Chrome).
-License
--------
+### License
-This bundle is under the MIT license. See the complete [license](LICENSE) in the bundle
+MIT - See [LICENSE](LICENSE)
diff --git a/config/services.yaml b/config/services.yaml
index 1919c4f..e6a95ac 100644
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -8,6 +8,12 @@ services:
calendar.serializer:
alias: CalendarBundle\Serializer\Serializer
+ CalendarBundle\Request\RequestParser:
+ class: CalendarBundle\Request\RequestParser
+
+ CalendarBundle\Request\RequestParserInterface:
+ alias: CalendarBundle\Request\RequestParser
+
CalendarBundle\Controller\CalendarController:
class: CalendarBundle\Controller\CalendarController
public: true
@@ -15,6 +21,7 @@ services:
arguments:
$eventDispatcher: '@event_dispatcher'
$serializer: '@CalendarBundle\Serializer\Serializer'
+ $requestParser: '@CalendarBundle\Request\RequestParser'
tags:
- controller.service_arguments
diff --git a/docs/configuration.md b/docs/configuration.md
new file mode 100644
index 0000000..c8dc93e
--- /dev/null
+++ b/docs/configuration.md
@@ -0,0 +1,140 @@
+# Configuration
+
+CalendarBundle provides optional configuration for caching and security.
+
+## Full Configuration Reference
+
+```yaml
+# config/packages/calendar.yaml
+calendar:
+ cache_max_age: 300 # HTTP cache max-age in seconds (default: 300, 0 to disable)
+ json_max_depth: 4 # Max JSON nesting for filters (default: 4, range: 1-512)
+```
+
+## HTTP Caching
+
+The bundle includes built-in HTTP caching using ETag headers for the `/fc-load-events` endpoint.
+
+### How It Works
+
+When `cache_max_age > 0`:
+- Response includes `Cache-Control: public, max-age=X` header
+- ETag header generated using xxh3 hash (fast, non-cryptographic)
+- Clients sending `If-None-Match` header receive `304 Not Modified` when content unchanged
+
+### Configuration
+
+```yaml
+calendar:
+ # Cache for 5 minutes (default)
+ cache_max_age: 300
+
+ # Cache for 1 hour
+ cache_max_age: 3600
+
+ # Disable caching entirely
+ cache_max_age: 0
+```
+
+### When to Disable Caching
+
+Set `cache_max_age: 0` if:
+- Events change frequently and must be real-time
+- You implement your own caching strategy
+- Testing/debugging requires fresh responses
+
+## JSON Filters Security
+
+The `filters` query parameter accepts JSON that is passed to your subscriber. The `json_max_depth` option limits nesting depth to prevent DoS attacks.
+
+### Configuration
+
+```yaml
+calendar:
+ # Default - sufficient for most use cases
+ json_max_depth: 4
+
+ # Allow deeper nesting if needed
+ json_max_depth: 10
+```
+
+### Example
+
+With `json_max_depth: 4`, these filters are valid:
+```json
+{"category": "meeting", "tags": ["work", "urgent"]}
+```
+
+But deeply nested structures will throw a `BadRequestHttpException`:
+```json
+{"a": {"b": {"c": {"d": {"e": "too deep"}}}}}
+```
+
+## Routing Configuration
+
+The bundle provides a default route. Import it in your application:
+
+```yaml
+# config/routes/calendar.yaml
+calendar:
+ resource: '@CalendarBundle/config/routing.yaml'
+```
+
+Or define your own route with custom path:
+
+```yaml
+# config/routes/calendar.yaml
+fc_load_events:
+ path: /api/calendar/events
+ controller: CalendarBundle\Controller\CalendarController::load
+```
+
+## Security
+
+### Access Control
+
+If your application uses Symfony's security firewall, configure access to the endpoint:
+
+```yaml
+# config/packages/security.yaml
+security:
+ access_control:
+ # Public access
+ - { path: ^/fc-load-events, roles: PUBLIC_ACCESS }
+
+ # Or restrict to authenticated users
+ - { path: ^/fc-load-events, roles: ROLE_USER }
+```
+
+### Custom Controller with Security Attributes
+
+For fine-grained control, wrap the bundle's controller:
+
+```php
+// src/Controller/CalendarController.php
+calendarController->load($request);
+ }
+}
+```
+
+Then remove or comment out `config/routes/calendar.yaml`.
diff --git a/docs/upgrade-to-82.md b/docs/upgrade-to-82.md
new file mode 100644
index 0000000..51031dd
--- /dev/null
+++ b/docs/upgrade-to-82.md
@@ -0,0 +1,145 @@
+# Migrating from v8.1.1 to v8.2
+
+This guide explains how to upgrade your application from CalendarBundle 8.1.1 to 8.2.
+
+## New Features
+
+### Bundle Configuration
+
+The bundle now supports configuration options. See [configuration.md](configuration.md) for details.
+
+```yaml
+# config/packages/calendar.yaml
+calendar:
+ cache_max_age: 300 # HTTP cache (0 to disable)
+ json_max_depth: 4 # JSON nesting limit for filters
+```
+
+### HTTP Caching
+
+The `/fc-load-events` endpoint now includes HTTP caching by default:
+- ETag headers using xxh3 hash
+- `Cache-Control: public, max-age=300`
+- Automatic `304 Not Modified` responses
+
+To disable: set `cache_max_age: 0` in configuration.
+
+### Fluent Interface
+
+Event setters now return `$this` for method chaining:
+
+```php
+$event = (new Event('Title', new \DateTime()))
+ ->setEnd(new \DateTime('+1 hour'))
+ ->setAllDay(false)
+ ->setResourceId('room-1')
+ ->addOption('color', 'blue');
+```
+
+### Custom Exceptions
+
+New exceptions for better error handling:
+
+| Exception | When Thrown |
+|-----------|-------------|
+| `InvalidDateException` | Missing or malformed `start`/`end` parameters |
+| `InvalidJsonException` | Invalid JSON in `filters` parameter |
+
+Both implement `CalendarExceptionInterface` for catch-all handling.
+
+## Breaking Changes
+
+### 1. Event::getOption() Return Value
+
+**Bug fix**: `getOption()` now returns `null` for missing keys instead of the key itself.
+
+**Before (buggy behavior):**
+```php
+$event->getOption('nonexistent'); // returned 'nonexistent'
+```
+
+**After (correct behavior):**
+```php
+$event->getOption('nonexistent'); // returns null
+$event->getOption('nonexistent', 'default'); // returns 'default'
+```
+
+**Migration**: If you relied on the buggy behavior, update your code to handle `null`.
+
+### 2. Event::setEnd(null) Behavior
+
+Setting `end` to `null` now automatically sets `allDay` to `true`.
+
+**Before:**
+```php
+$event->setAllDay(false);
+$event->setEnd(null);
+// allDay remained false
+```
+
+**After:**
+```php
+$event->setAllDay(false);
+$event->setEnd(null);
+// allDay is now true (no end = all day event)
+```
+
+**Migration**: If you need a non-all-day event without end time, this is logically inconsistent with FullCalendar. Review your use case.
+
+### 3. Event::setAllDay(true) Normalizes Start Time
+
+Setting `allDay` to `true` now normalizes the start time to midnight.
+
+**Before:**
+```php
+$event = new Event('Title', new \DateTime('2024-01-15 14:30:00'));
+$event->setAllDay(true);
+// start remained 2024-01-15 14:30:00
+```
+
+**After:**
+```php
+$event = new Event('Title', new \DateTime('2024-01-15 14:30:00'));
+$event->setAllDay(true);
+// start is now 2024-01-15 00:00:00
+```
+
+**Migration**: This ensures consistency with FullCalendar. No action needed unless you relied on preserving time for all-day events.
+
+### 4. JSON Depth Limit
+
+The `filters` parameter now has a default depth limit of 4 levels. Deeply nested JSON will throw `BadRequestHttpException`.
+
+**Migration**: If your application uses deeply nested filters, increase `json_max_depth` in configuration.
+
+## Migration Steps
+
+1. **Update the bundle**:
+ ```bash
+ composer update tattali/calendar-bundle
+ ```
+
+2. **Review Event::getOption() usage** in your code for null handling
+
+3. **Optional**: Add configuration file if you need non-default values:
+ ```yaml
+ # config/packages/calendar.yaml
+ calendar:
+ cache_max_age: 300
+ json_max_depth: 4
+ ```
+
+4. **Clear cache**:
+ ```bash
+ php bin/console cache:clear
+ ```
+
+## No Changes Required
+
+The following remain fully backward compatible:
+
+- Event subscriber pattern (`SetDataEvent`)
+- API endpoint path (`/fc-load-events`)
+- Event entity constructor and basic usage
+- Frontend JavaScript integration
+- Service IDs and aliases
diff --git a/phpstan.neon b/phpstan.neon
new file mode 100644
index 0000000..08566d0
--- /dev/null
+++ b/phpstan.neon
@@ -0,0 +1,5 @@
+parameters:
+ level: 9
+ paths:
+ - src
+ - tests
diff --git a/src/CalendarBundle.php b/src/CalendarBundle.php
index aa3ff95..d015d68 100644
--- a/src/CalendarBundle.php
+++ b/src/CalendarBundle.php
@@ -4,12 +4,35 @@
namespace CalendarBundle;
+use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use Symfony\Component\HttpKernel\Bundle\AbstractBundle;
class CalendarBundle extends AbstractBundle
{
+ public function configure(DefinitionConfigurator $definition): void
+ {
+ $rootNode = $definition->rootNode();
+ \assert($rootNode instanceof \Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition);
+
+ $rootNode
+ ->children()
+ ->integerNode('cache_max_age')
+ ->defaultValue(300)
+ ->min(0)
+ ->info('HTTP cache max-age in seconds (0 to disable)')
+ ->end()
+ ->integerNode('json_max_depth')
+ ->defaultValue(4)
+ ->min(1)
+ ->max(512)
+ ->info('Maximum JSON nesting depth for filters parameter')
+ ->end()
+ ->end()
+ ;
+ }
+
/**
* @param array $config
*/
@@ -19,5 +42,15 @@ public function loadExtension(
ContainerBuilder $builder,
): void {
$container->import('../config/services.yaml');
+
+ $container->services()
+ ->get('CalendarBundle\Controller\CalendarController')
+ ->arg('$cacheMaxAge', $config['cache_max_age'])
+ ;
+
+ $container->services()
+ ->get('CalendarBundle\Request\RequestParser')
+ ->arg('$jsonMaxDepth', $config['json_max_depth'])
+ ;
}
}
diff --git a/src/Controller/CalendarController.php b/src/Controller/CalendarController.php
index 0620415..ea6ea74 100755
--- a/src/Controller/CalendarController.php
+++ b/src/Controller/CalendarController.php
@@ -5,6 +5,8 @@
namespace CalendarBundle\Controller;
use CalendarBundle\Event\SetDataEvent;
+use CalendarBundle\Exception\CalendarExceptionInterface;
+use CalendarBundle\Request\RequestParserInterface;
use CalendarBundle\Serializer\SerializerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -17,53 +19,44 @@ class CalendarController
public function __construct(
private readonly EventDispatcherInterface $eventDispatcher,
private readonly SerializerInterface $serializer,
+ private readonly RequestParserInterface $requestParser,
+ private readonly int $cacheMaxAge = 300,
) {}
public function load(Request $request): JsonResponse
{
try {
- $start = $request->query->getString('start');
- if ($start) {
- try {
- $start = new \DateTime($start);
- } catch (\DateMalformedStringException $e) {
- throw new \UnexpectedValueException('Query parameter "start" is not a valid date', previous: $e);
- }
- } else {
- throw new \UnexpectedValueException('Query parameter "start" should be a string');
- }
-
- $end = $request->query->getString('end');
- if ($end) {
- try {
- $end = new \DateTime($end);
- } catch (\DateMalformedStringException $e) {
- throw new \UnexpectedValueException('Query parameter "end" is not a valid date', previous: $e);
- }
- } else {
- throw new \UnexpectedValueException('Query parameter "end" should be a string');
- }
-
- try {
- $filters = $request->query->getString('filters', '{}');
- /**
- * @var mixed[]
- */
- $filters = json_decode($filters, true, flags: \JSON_THROW_ON_ERROR);
- } catch (\JsonException $e) {
- throw new \UnexpectedValueException('Query parameter "filters" is not a valid JSON', previous: $e);
- }
- } catch (\UnexpectedValueException $e) {
+ $params = $this->requestParser->parse($request);
+ } catch (CalendarExceptionInterface $e) {
throw new BadRequestHttpException($e->getMessage(), $e);
}
- $setDataEvent = $this->eventDispatcher->dispatch(new SetDataEvent($start, $end, $filters));
+ $setDataEvent = $this->eventDispatcher->dispatch(
+ new SetDataEvent($params->start, $params->end, $params->filters),
+ );
$content = $this->serializer->serialize($setDataEvent->getEvents());
- return JsonResponse::fromJsonString(
- $content,
- empty($content) ? Response::HTTP_NO_CONTENT : Response::HTTP_OK,
- );
+ if ('' === $content) {
+ $response = new JsonResponse();
+ $response->setStatusCode(Response::HTTP_NO_CONTENT);
+ $response->setContent('');
+
+ return $response;
+ }
+
+ $response = JsonResponse::fromJsonString($content);
+
+ if ($this->cacheMaxAge > 0) {
+ $response->setETag(hash('xxh3', $content));
+ $response->setPublic();
+ $response->setMaxAge($this->cacheMaxAge);
+
+ if ($response->isNotModified($request)) {
+ return $response;
+ }
+ }
+
+ return $response;
}
}
diff --git a/src/Entity/Event.php b/src/Entity/Event.php
index c256f16..bf4bc74 100755
--- a/src/Entity/Event.php
+++ b/src/Entity/Event.php
@@ -22,27 +22,32 @@ public function __construct(
$this->setStart($start);
}
- public function getTitle(): ?string
+ public function getTitle(): string
{
return $this->title;
}
- public function setTitle(string $title): void
+ public function setTitle(string $title): self
{
$this->title = $title;
+
+ return $this;
}
- public function getStart(): ?\DateTime
+ public function getStart(): \DateTime
{
return $this->start;
}
- public function setStart(\DateTime $start): void
+ public function setStart(\DateTime $start): self
{
if ($this->allDay) {
+ $start = clone $start;
$start->setTime(0, 0, 0, 0);
}
$this->start = $start;
+
+ return $this;
}
public function getEnd(): ?\DateTime
@@ -50,12 +55,12 @@ public function getEnd(): ?\DateTime
return $this->end;
}
- public function setEnd(?\DateTime $end): void
+ public function setEnd(?\DateTime $end): self
{
- if ($end) {
- $this->allDay = false;
- }
+ $this->allDay = null === $end;
$this->end = $end;
+
+ return $this;
}
public function isAllDay(): bool
@@ -63,9 +68,15 @@ public function isAllDay(): bool
return $this->allDay;
}
- public function setAllDay(bool $allDay): void
+ public function setAllDay(bool $allDay): self
{
$this->allDay = $allDay;
+ if ($allDay) {
+ $this->start = clone $this->start;
+ $this->start->setTime(0, 0, 0, 0);
+ }
+
+ return $this;
}
public function getResourceId(): ?string
@@ -73,9 +84,11 @@ public function getResourceId(): ?string
return $this->resourceId;
}
- public function setResourceId(?string $resourceId): void
+ public function setResourceId(?string $resourceId): self
{
$this->resourceId = $resourceId;
+
+ return $this;
}
/**
@@ -89,19 +102,23 @@ public function getOptions(): array
/**
* @param mixed[] $options
*/
- public function setOptions(array $options): void
+ public function setOptions(array $options): self
{
$this->options = $options;
+
+ return $this;
}
public function getOption(string $name): mixed
{
- return $this->options[$name];
+ return $this->options[$name] ?? null;
}
- public function addOption(string $name, mixed $value): void
+ public function addOption(string $name, mixed $value): self
{
$this->options[$name] = $value;
+
+ return $this;
}
public function removeOption(string $name): mixed
@@ -122,19 +139,19 @@ public function removeOption(string $name): mixed
public function toArray(): array
{
$event = [
- 'title' => $this->getTitle(),
- 'start' => $this->getStart()?->format(\DateTime::ATOM),
- 'allDay' => $this->isAllDay(),
+ 'title' => $this->title,
+ 'start' => $this->start->format(\DateTime::ATOM),
+ 'allDay' => $this->allDay,
];
- if ($this->getEnd()) {
- $event['end'] = $this->getEnd()->format(\DateTime::ATOM);
+ if (null !== $this->end) {
+ $event['end'] = $this->end->format(\DateTime::ATOM);
}
- if ($this->getResourceId()) {
- $event['resourceId'] = $this->getResourceId();
+ if (null !== $this->resourceId) {
+ $event['resourceId'] = $this->resourceId;
}
- return [...$event, ...$this->getOptions()];
+ return $event + $this->options;
}
}
diff --git a/src/Event/SetDataEvent.php b/src/Event/SetDataEvent.php
index c60784a..3a721f1 100644
--- a/src/Event/SetDataEvent.php
+++ b/src/Event/SetDataEvent.php
@@ -61,4 +61,9 @@ public function getEvents(): array
{
return $this->events;
}
+
+ public function getEventCount(): int
+ {
+ return \count($this->events);
+ }
}
diff --git a/src/Exception/CalendarExceptionInterface.php b/src/Exception/CalendarExceptionInterface.php
new file mode 100644
index 0000000..6d47184
--- /dev/null
+++ b/src/Exception/CalendarExceptionInterface.php
@@ -0,0 +1,7 @@
+ */
+ private readonly int $jsonMaxDepth;
+
+ /**
+ * @param int<1, 512> $jsonMaxDepth
+ */
+ public function __construct(
+ int $jsonMaxDepth = 4,
+ ) {
+ $this->jsonMaxDepth = $jsonMaxDepth;
+ }
+
+ public function parse(Request $request): QueryParameters
+ {
+ return new QueryParameters(
+ $this->parseDate($request, 'start'),
+ $this->parseDate($request, 'end'),
+ $this->parseFilters($request),
+ );
+ }
+
+ private function parseDate(Request $request, string $parameter): \DateTime
+ {
+ $value = $request->query->getString($parameter);
+
+ if ('' === $value) {
+ throw InvalidDateException::missingParameter($parameter);
+ }
+
+ try {
+ return new \DateTime($value);
+ } catch (\DateMalformedStringException $e) {
+ throw InvalidDateException::forParameter($parameter, $e);
+ }
+ }
+
+ /**
+ * @return mixed[]
+ */
+ private function parseFilters(Request $request): array
+ {
+ $value = $request->query->getString('filters', '{}');
+
+ try {
+ $filters = json_decode($value, associative: true, depth: $this->jsonMaxDepth, flags: \JSON_THROW_ON_ERROR);
+ \assert(\is_array($filters));
+
+ return $filters;
+ } catch (\JsonException $e) {
+ throw InvalidJsonException::forParameter('filters', $e);
+ }
+ }
+}
diff --git a/src/Request/RequestParserInterface.php b/src/Request/RequestParserInterface.php
new file mode 100644
index 0000000..448a06f
--- /dev/null
+++ b/src/Request/RequestParserInterface.php
@@ -0,0 +1,18 @@
+eventDispatcher = $this->createMock(EventDispatcherInterface::class);
$this->serializer = $this->createMock(SerializerInterface::class);
+ $this->requestParser = new RequestParser();
$this->controller = new CalendarController(
$this->eventDispatcher,
$this->serializer,
+ $this->requestParser,
+ cacheMaxAge: 300,
);
}
@@ -78,9 +83,59 @@ public function testItProvidesAnEventsFeedForACalendar(): void
$response = $this->controller->load($request);
self::assertInstanceOf(JsonResponse::class, $response);
-
self::assertSame($data, $response->getContent());
self::assertSame(JsonResponse::HTTP_OK, $response->getStatusCode());
+
+ // Verify cache headers
+ self::assertTrue($response->headers->has('ETag'));
+ self::assertStringContainsString('public', (string) $response->headers->get('Cache-Control'));
+ self::assertStringContainsString('max-age=300', (string) $response->headers->get('Cache-Control'));
+ }
+
+ public function testItReturns304WhenEtagMatches(): void
+ {
+ $data = '[{"title":"Event","start":"2016-03-01T12:00:00Z","allDay":true}]';
+ $etag = hash('xxh3', $data);
+
+ $request = Request::create('/fc-load-events', parameters: [
+ 'start' => '2016-03-01',
+ 'end' => '2016-03-19',
+ ]);
+ $request->headers->set('If-None-Match', '"' . $etag . '"');
+
+ $this->calendarEvent->method('getEvents')->willReturn([$this->event]);
+ $this->eventDispatcher->method('dispatch')->willReturn($this->calendarEvent);
+ $this->serializer->method('serialize')->willReturn($data);
+
+ $response = $this->controller->load($request);
+
+ self::assertSame(JsonResponse::HTTP_NOT_MODIFIED, $response->getStatusCode());
+ }
+
+ public function testCacheDisabledWhenMaxAgeIsZero(): void
+ {
+ $controller = new CalendarController(
+ $this->eventDispatcher,
+ $this->serializer,
+ $this->requestParser,
+ cacheMaxAge: 0,
+ );
+
+ $request = Request::create('/fc-load-events', parameters: [
+ 'start' => '2016-03-01',
+ 'end' => '2016-03-19',
+ ]);
+
+ $data = '[{"title":"Event"}]';
+ $this->calendarEvent->method('getEvents')->willReturn([$this->event]);
+ $this->eventDispatcher->method('dispatch')->willReturn($this->calendarEvent);
+ $this->serializer->method('serialize')->willReturn($data);
+
+ $response = $controller->load($request);
+
+ self::assertSame(JsonResponse::HTTP_OK, $response->getStatusCode());
+ self::assertFalse($response->headers->has('ETag'));
+ self::assertStringNotContainsString('public', (string) $response->headers->get('Cache-Control'));
}
public function testItNotFindAnyEvents(): void
diff --git a/tests/DependencyInjection/CalendarBundleTest.php b/tests/DependencyInjection/CalendarBundleTest.php
index 9f86290..7921fc1 100644
--- a/tests/DependencyInjection/CalendarBundleTest.php
+++ b/tests/DependencyInjection/CalendarBundleTest.php
@@ -74,7 +74,10 @@ public function testBundleLoadExtension(): void
$configurator = new ContainerConfigurator($container, $loader, $instanceOf, $bundlePath, 'calendar_test');
- $bundle->loadExtension([], $configurator, $container);
+ $bundle->loadExtension([
+ 'cache_max_age' => 300,
+ 'json_max_depth' => 4,
+ ], $configurator, $container);
self::assertTrue($container->hasDefinition(Serializer::class));
self::assertTrue($container->hasDefinition(CalendarController::class));
diff --git a/tests/Entity/EventTest.php b/tests/Entity/EventTest.php
index 03a2cba..7b781ef 100755
--- a/tests/Entity/EventTest.php
+++ b/tests/Entity/EventTest.php
@@ -60,6 +60,7 @@ public function testItShouldConvertItsValuesInToArray(): void
$this->entity->removeOption('be-removed');
self::assertNull($this->entity->removeOption('no-found-key'));
+ self::assertNull($this->entity->getOption('non-existent-key'));
$this->entity->setOptions($options);
self::assertSame($options, $this->entity->getOptions());
@@ -105,4 +106,66 @@ public function testItSetPropertiesAccordingly(): void
$this->entity->setResourceId($newValue);
self::assertSame($newValue, $this->entity->getResourceId());
}
+
+ public function testSetStartDoesNotMutateOriginalDateTimeForAllDayEvent(): void
+ {
+ $originalTime = '14:30:00';
+ $date = new \DateTime('2024-01-15 ' . $originalTime);
+
+ $event = new Event('All day event', $date);
+
+ self::assertTrue($event->isAllDay());
+ self::assertSame($originalTime, $date->format('H:i:s'), 'Original DateTime should not be mutated');
+ self::assertSame('00:00:00', $event->getStart()->format('H:i:s'), 'Event start time should be zeroed');
+ }
+
+ public function testSetEndNullResetsAllDayToTrue(): void
+ {
+ $event = new Event(
+ 'Meeting',
+ new \DateTime('2024-01-15 14:00:00'),
+ new \DateTime('2024-01-15 15:00:00'),
+ );
+
+ self::assertFalse($event->isAllDay());
+
+ $event->setEnd(null);
+
+ self::assertTrue($event->isAllDay());
+ }
+
+ public function testSetAllDayTrueNormalizesStartTime(): void
+ {
+ $event = new Event(
+ 'Meeting',
+ new \DateTime('2024-01-15 14:30:00'),
+ new \DateTime('2024-01-15 15:30:00'),
+ );
+
+ self::assertFalse($event->isAllDay());
+ self::assertSame('14:30:00', $event->getStart()->format('H:i:s'));
+
+ $event->setAllDay(true);
+
+ self::assertTrue($event->isAllDay());
+ self::assertSame('00:00:00', $event->getStart()->format('H:i:s'));
+ }
+
+ public function testFluentInterface(): void
+ {
+ $event = new Event('Initial', new \DateTime('2024-01-01'));
+
+ $result = $event
+ ->setTitle('Updated')
+ ->setResourceId('resource-1')
+ ->addOption('color', 'blue')
+ ->addOption('url', 'https://example.com')
+ ;
+
+ self::assertSame($event, $result);
+ self::assertSame('Updated', $event->getTitle());
+ self::assertSame('resource-1', $event->getResourceId());
+ self::assertSame('blue', $event->getOption('color'));
+ self::assertSame('https://example.com', $event->getOption('url'));
+ }
}
diff --git a/tests/Event/SetDataEventTest.php b/tests/Event/SetDataEventTest.php
index be650ad..4fb75e1 100755
--- a/tests/Event/SetDataEventTest.php
+++ b/tests/Event/SetDataEventTest.php
@@ -45,15 +45,19 @@ public function testItHasRequireValues(): void
public function testItHandleEvents(): void
{
self::assertCount(0, $this->event->getEvents());
+ self::assertSame(0, $this->event->getEventCount());
$this->event->addEvent($this->eventEntity);
self::assertSame([$this->eventEntity], $this->event->getEvents());
self::assertCount(1, $this->event->getEvents());
+ self::assertSame(1, $this->event->getEventCount());
$this->event->addEvent($this->eventEntity);
self::assertCount(2, $this->event->getEvents());
+ self::assertSame(2, $this->event->getEventCount());
$this->event->addEvent($this->eventEntity2);
self::assertCount(3, $this->event->getEvents());
+ self::assertSame(3, $this->event->getEventCount());
}
}
diff --git a/tests/Request/RequestParserTest.php b/tests/Request/RequestParserTest.php
new file mode 100644
index 0000000..ea564dd
--- /dev/null
+++ b/tests/Request/RequestParserTest.php
@@ -0,0 +1,141 @@
+parser = new RequestParser();
+ }
+
+ public function testParseValidRequest(): void
+ {
+ $request = Request::create('/fc-load-events', parameters: [
+ 'start' => '2024-01-01',
+ 'end' => '2024-01-31',
+ 'filters' => '{"category":"meeting"}',
+ ]);
+
+ $params = $this->parser->parse($request);
+
+ self::assertInstanceOf(QueryParameters::class, $params);
+ self::assertSame('2024-01-01', $params->start->format('Y-m-d'));
+ self::assertSame('2024-01-31', $params->end->format('Y-m-d'));
+ self::assertSame(['category' => 'meeting'], $params->filters);
+ }
+
+ public function testParseDefaultFilters(): void
+ {
+ $request = Request::create('/fc-load-events', parameters: [
+ 'start' => '2024-01-01',
+ 'end' => '2024-01-31',
+ ]);
+
+ $params = $this->parser->parse($request);
+
+ self::assertSame([], $params->filters);
+ }
+
+ public function testParseMissingStartThrowsException(): void
+ {
+ $this->expectException(InvalidDateException::class);
+ $this->expectExceptionMessage('Query parameter "start" is required');
+
+ $request = Request::create('/fc-load-events', parameters: [
+ 'end' => '2024-01-31',
+ ]);
+
+ $this->parser->parse($request);
+ }
+
+ public function testParseMissingEndThrowsException(): void
+ {
+ $this->expectException(InvalidDateException::class);
+ $this->expectExceptionMessage('Query parameter "end" is required');
+
+ $request = Request::create('/fc-load-events', parameters: [
+ 'start' => '2024-01-01',
+ ]);
+
+ $this->parser->parse($request);
+ }
+
+ public function testParseInvalidStartDateThrowsException(): void
+ {
+ $this->expectException(InvalidDateException::class);
+ $this->expectExceptionMessage('Query parameter "start" is not a valid date');
+
+ $request = Request::create('/fc-load-events', parameters: [
+ 'start' => 'not-a-date',
+ 'end' => '2024-01-31',
+ ]);
+
+ $this->parser->parse($request);
+ }
+
+ public function testParseInvalidEndDateThrowsException(): void
+ {
+ $this->expectException(InvalidDateException::class);
+ $this->expectExceptionMessage('Query parameter "end" is not a valid date');
+
+ $request = Request::create('/fc-load-events', parameters: [
+ 'start' => '2024-01-01',
+ 'end' => 'invalid',
+ ]);
+
+ $this->parser->parse($request);
+ }
+
+ public function testParseInvalidJsonFiltersThrowsException(): void
+ {
+ $this->expectException(InvalidJsonException::class);
+ $this->expectExceptionMessage('Query parameter "filters" is not valid JSON');
+
+ $request = Request::create('/fc-load-events', parameters: [
+ 'start' => '2024-01-01',
+ 'end' => '2024-01-31',
+ 'filters' => '{invalid}',
+ ]);
+
+ $this->parser->parse($request);
+ }
+
+ public function testParseNestedFiltersWithinDepthLimit(): void
+ {
+ $request = Request::create('/fc-load-events', parameters: [
+ 'start' => '2024-01-01',
+ 'end' => '2024-01-31',
+ 'filters' => '{"level1":{"level2":{"level3":"value"}}}',
+ ]);
+
+ $params = $this->parser->parse($request);
+
+ self::assertSame(['level1' => ['level2' => ['level3' => 'value']]], $params->filters);
+ }
+
+ public function testParseDeeplyNestedFiltersThrowsException(): void
+ {
+ $this->expectException(InvalidJsonException::class);
+
+ // Depth 5 exceeds MAX_JSON_DEPTH of 4
+ $request = Request::create('/fc-load-events', parameters: [
+ 'start' => '2024-01-01',
+ 'end' => '2024-01-31',
+ 'filters' => '{"l1":{"l2":{"l3":{"l4":{"l5":"too deep"}}}}}',
+ ]);
+
+ $this->parser->parse($request);
+ }
+}