feat: accept form submissions from the websites over an API endpoint

This commit is contained in:
fromme committed 2026-09-24 16:35:06 +02:00
1 parent f48e53fdd0
commit 30ad5e8488
17 files changed
+1350 -4

No files matched your search

+2
View File
@@ -82,6 +82,8 @@ MYEP_OAUTH2_URL_AUTHORIZE=https://my.ep-reisen.de/authorize
MYEP_OAUTH2_URL_ACCESS_TOKEN=https://my.ep-reisen.de/token MYEP_OAUTH2_URL_ACCESS_TOKEN=https://my.ep-reisen.de/token
MYEP_OAUTH2_URL_RESOURCE_OWNER_DETAILS=https://my.ep-reisen.de/api/userinfo MYEP_OAUTH2_URL_RESOURCE_OWNER_DETAILS=https://my.ep-reisen.de/api/userinfo
MYEP_OAUTH2_SCOPES=email,id,roles,profile MYEP_OAUTH2_SCOPES=email,id,roles,profile
# Shared key the sibling websites send in the X-Api-Key header. Empty here: set it per stage.
APPLICATION_FORM_API_KEY=
###> symfony/mailjet-mailer ### ###> symfony/mailjet-mailer ###
# Bulk teamer mailings only, everything else goes out over MAILER_DSN. Set the real # Bulk teamer mailings only, everything else goes out over MAILER_DSN. Set the real
+4
View File
@@ -6,3 +6,7 @@ PANTHER_APP_ENV=panther
PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots
# the test client requests http://localhost/ # the test client requests http://localhost/
APP_TRUSTED_HOSTS='^localhost$' APP_TRUSTED_HOSTS='^localhost$'
# The api firewall rejects every request while no key is configured, and .env.local is not
# loaded in the test environment - so the throwaway key the tests send belongs here.
# Keep it in sync with ApplicationFormControllerTest::KEY.
APPLICATION_FORM_API_KEY=test-application-form-api-key
+57
View File
@@ -0,0 +1,57 @@
### Full submission, as the TYPO3 ApplicationApiFinisher sends it.
### The key is APPLICATION_FORM_API_KEY of the stage being called; keep the real values in
### http-client.private.env.json, which is not in the repository.
POST https://myep-team.ddev.site/api/application-forms
Content-Type: application/json
X-Api-Key: {{applicationFormApiKey}}
{
"form": "applicationform",
"formLabel": "Bewerbungsformular",
"submittedAt": "2026-09-22T12:30:00+02:00",
"pageUid": 1421,
"pageUrl": "https://www.ep-reisen.de/jobs/bewerbung",
"firstName": "Anna",
"lastName": "Muster",
"email": "[email protected]",
"phone": "+49 30 1234567",
"mobile": "+49 170 1234567",
"birthDate": "01.01.1990",
"street": "Musterstraße 1",
"zipCode": "10115",
"city": "Berlin",
"details": {
"Nationalität": "deutsch",
"Führerschein": "ja",
"Ausbildung/Studium": "Tourismusmanagement",
"Bewerbung als": ["Reiseleitung", "Skilehrer"],
"Ich fahre": ["Ski", "Snowboard"],
"Wie gut fahre ich Ski": "fortgeschritten",
"Skifahrer seit": "2004",
"Einsatz datum": "ab 20.12.2026"
}
}
### A form that maps nothing but the basics - valid, everything else is absent, not empty.
POST https://myep-team.ddev.site/api/application-forms
Content-Type: application/json
X-Api-Key: {{applicationFormApiKey}}
{
"form": "contactform",
"formLabel": "Kontaktformular",
"submittedAt": "2026-09-22T12:30:00+02:00",
"details": {}
}
### Wrong key: 401, and the submission is dropped on the TYPO3 side without a retry.
POST https://myep-team.ddev.site/api/application-forms
Content-Type: application/json
X-Api-Key: definitely-not-the-key
{
"form": "applicationform",
"formLabel": "Bewerbungsformular",
"submittedAt": "2026-09-22T12:30:00+02:00",
"details": {}
}
+19 -4
View File
@@ -3,6 +3,7 @@ monolog:
- deprecation - deprecation
- bpn - bpn
- audit - audit
- api
handlers: handlers:
main: main:
@@ -11,7 +12,7 @@ monolog:
handler: exceptions handler: exceptions
excluded_http_codes: [404, 405] excluded_http_codes: [404, 405]
buffer_size: 50 buffer_size: 50
channels: ["!bpn", "!audit"] channels: ["!bpn", "!audit", "!api"]
exceptions: exceptions:
type: rotating_file type: rotating_file
path: "%kernel.logs_dir%/framework.%kernel.environment%.log" path: "%kernel.logs_dir%/framework.%kernel.environment%.log"
@@ -29,6 +30,14 @@ monolog:
path: '%kernel.logs_dir%/audit.%kernel.environment%.log' path: '%kernel.logs_dir%/audit.%kernel.environment%.log'
max_files: 5 max_files: 5
level: info level: info
# Form submissions posted by the sibling websites. Own file because it is machine
# traffic nobody watches between incidents, and it must stay out of the audit table.
api:
channels: [ "api" ]
type: rotating_file
path: '%kernel.logs_dir%/api.%kernel.environment%.log'
max_files: 30
level: info
database: database:
channels: ["audit"] channels: ["audit"]
type: service type: service
@@ -36,7 +45,7 @@ monolog:
console: console:
type: console type: console
process_psr_3_messages: false process_psr_3_messages: false
channels: ["!event", "!bpn", "!doctrine", "!console"] channels: ["!event", "!bpn", "!api", "!doctrine", "!console"]
when@dev: when@dev:
monolog: monolog:
@@ -73,7 +82,7 @@ when@prod:
handler: exceptions handler: exceptions
excluded_http_codes: [404, 405] excluded_http_codes: [404, 405]
buffer_size: 50 buffer_size: 50
channels: ["!bpn", "!audit"] channels: ["!bpn", "!audit", "!api"]
exceptions: exceptions:
type: rotating_file type: rotating_file
path: "%kernel.logs_dir%/framework.%kernel.environment%.log" path: "%kernel.logs_dir%/framework.%kernel.environment%.log"
@@ -91,6 +100,12 @@ when@prod:
path: '%kernel.logs_dir%/audit.%kernel.environment%.log' path: '%kernel.logs_dir%/audit.%kernel.environment%.log'
max_files: 5 max_files: 5
level: info level: info
api:
channels: [ "api" ]
type: rotating_file
path: '%kernel.logs_dir%/api.%kernel.environment%.log'
max_files: 30
level: info
database: database:
channels: ["audit"] channels: ["audit"]
type: service type: service
@@ -98,4 +113,4 @@ when@prod:
console: console:
type: console type: console
process_psr_3_messages: false process_psr_3_messages: false
channels: ["!event", "!doctrine", "!bpn", "!audit"] channels: ["!event", "!doctrine", "!bpn", "!audit", "!api"]
+19
View File
@@ -7,6 +7,12 @@ security:
entity: entity:
class: App\Entity\User class: App\Entity\User
property: email property: email
# The sibling websites post as this one; it exists in configuration only. An API caller
# must never be a row in the user table, where it would be a login to the UI as well.
api_user_provider:
memory:
users:
api: { roles: [ ROLE_API ] }
role_hierarchy: role_hierarchy:
ROLE_TEAM_ADMIN: [ ROLE_ADMINISTRATIVE ] ROLE_TEAM_ADMIN: [ ROLE_ADMINISTRATIVE ]
@@ -16,6 +22,17 @@ security:
dev: dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/ pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false security: false
# Stateless and key-authenticated: a machine posts here inside a visitor's form
# submission on another site, so there is no session, no login form and no redirect.
# Must stay above "main", whose pattern is ^/.
api:
pattern: ^/api
stateless: true
provider: api_user_provider
access_token:
token_handler: App\Security\AccessToken\ApiKeyTokenHandler
token_extractors:
- App\Security\AccessToken\ApiKeyExtractor
main: main:
lazy: true lazy: true
provider: bpn_user_provider provider: bpn_user_provider
@@ -29,6 +46,8 @@ security:
path: app_security_logout path: app_security_logout
target: app_security_login target: app_security_login
access_control: access_control:
# The firewall authenticates, the controller requires ROLE_API
- { path: '^/api', roles: PUBLIC_ACCESS, requires_channel: https }
- { path: '^/_uploader', roles: ROLE_USER, requires_channel: https } - { path: '^/_uploader', roles: ROLE_USER, requires_channel: https }
- { path: '^/', roles: PUBLIC_ACCESS, requires_channel: https } - { path: '^/', roles: PUBLIC_ACCESS, requires_channel: https }
+7
View File
@@ -0,0 +1,7 @@
# Enabled for #[MapRequestPayload] on the api controllers - nothing else in this application
# serializes. ALLOW_EXTRA_ATTRIBUTES stays at its default (true) on purpose: the TYPO3 form
# payload grows keys without a release here, and rejecting an unknown one would turn every
# later mapping change into a 400 (see .ddev/plans/application-form-endpoint.md).
framework:
serializer:
name_converter: ~
+17
View File
@@ -35,6 +35,11 @@ parameters:
1461: 'SHM' 1461: 'SHM'
1462: 'AGR' 1462: 'AGR'
# Shared key the sibling websites authenticate their form submissions with. Empty here on
# purpose - the real value belongs in the environment of each stage, and an empty key
# rejects every request instead of letting them through.
application_form_api_key: '%env(string:APPLICATION_FORM_API_KEY)%'
default_email_from: '%env(APP_DEFAULT_EMAIL_FROM)%' default_email_from: '%env(APP_DEFAULT_EMAIL_FROM)%'
default_email_to: '%env(APP_DEFAULT_EMAIL_TO)%' default_email_to: '%env(APP_DEFAULT_EMAIL_TO)%'
@@ -318,6 +323,18 @@ services:
from: '%default_email_from%' from: '%default_email_from%'
to: '%default_email_to%' to: '%default_email_to%'
# Its own log channel, not the audit one bound by default: the audit channel writes a row
# to the log table through App\Logging\DatabaseHandler, which records what staff did here.
App\Security\AccessToken\ApiKeyTokenHandler:
arguments:
$apiKey: '%application_form_api_key%'
$logger: '@monolog.logger.api'
App\Controller\Api\ApplicationFormController:
arguments:
$logger: '@monolog.logger.api'
tags: [ 'controller.service_arguments' ]
App\Security\OAuth2\MyEpClient: App\Security\OAuth2\MyEpClient:
arguments: arguments:
$options: $options:
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Form submissions posted by the sibling websites (TYPO3 ApplicationApiFinisher).
*
* Generated with doctrine:migrations:diff and reduced to these two statements: the diff also
* swept in the long-standing dev drift in the datetime and json column declarations, plus
* tables that only exist on another branch.
*/
final class Version20260923062331 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add application_form_submission for form submissions posted by the websites';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE application_form_submission (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, form VARCHAR(64) NOT NULL, form_label VARCHAR(255) NOT NULL, submitted_at DATETIME NOT NULL, page_uid INT DEFAULT NULL, page_url VARCHAR(2048) DEFAULT NULL, first_name VARCHAR(255) DEFAULT NULL, last_name VARCHAR(255) DEFAULT NULL, email VARCHAR(255) DEFAULT NULL, phone VARCHAR(64) DEFAULT NULL, mobile VARCHAR(64) DEFAULT NULL, birth_date VARCHAR(64) DEFAULT NULL, street VARCHAR(255) DEFAULT NULL, zip_code VARCHAR(32) DEFAULT NULL, city VARCHAR(255) DEFAULT NULL, details JSON NOT NULL, payload JSON NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME DEFAULT NULL, UNIQUE INDEX UNIQ_F47CE995D17F50A6 (uuid), INDEX IDX_F47CE995B5265E72 (form, submitted_at), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE application_form_submission');
}
}
@@ -0,0 +1,83 @@
<?php
namespace App\Controller\Api;
use App\Entity\ApplicationFormSubmission;
use App\Model\ApplicationFormSubmissionDto;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Receives a form submitted on one of the public websites, posted by the TYPO3
* ApplicationApiFinisher. Authenticated by the API key of the stateless "api" firewall.
*
* The visitor waits for this response inside their own form submission and TYPO3 gives up after
* a few seconds without retrying, so this stores the row and answers - notification mail and any
* matching against known people belong in a worker, not here
* (.ddev/plans/MYEP-APPLICATION-ENDPOINT.md §6).
*/
class ApplicationFormController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
/**
* A failed mapping or validation answers 400 rather than the resolver's default 422: the
* contract names 400 for an unusable payload, and the TYPO3 side logs the response body.
* Errors are English - the only caller is a machine.
*/
#[Route('/api/application-forms', name: 'app_api_application_form_submit', methods: ['POST'])]
#[IsGranted('ROLE_API')]
public function submit(
Request $request,
#[MapRequestPayload(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
ApplicationFormSubmissionDto $dto,
): JsonResponse {
$submission = $this->store($dto, $request);
$this->logger->info('Application form submission received', [
'uuid' => $submission->getUuid(),
'form' => $submission->getForm(),
'formLabel' => $submission->getFormLabel(),
'pageUid' => $submission->getPageUid(),
'submittedAt' => $submission->getSubmittedAt()->format(\DateTimeInterface::ATOM),
// the labels and the answers are personal data (§7), their number is not
'detailCount' => count($submission->getDetails()),
]);
return new JsonResponse(
['status' => 'accepted', 'uuid' => $submission->getUuid()],
Response::HTTP_ACCEPTED
);
}
private function store(ApplicationFormSubmissionDto $dto, Request $request): ApplicationFormSubmission
{
$submission = ApplicationFormSubmission::fromDto($dto, $this->decodePayload($request));
$this->entityManager->persist($submission);
$this->entityManager->flush();
return $submission;
}
/**
* @return array<string, mixed>
*/
private function decodePayload(Request $request): array
{
$payload = json_decode($request->getContent(), true);
return is_array($payload) ? $payload : [];
}
}
+347
View File
@@ -0,0 +1,347 @@
<?php
namespace App\Entity;
use App\Entity\Traits\TimestampableEntity;
use App\Model\ApplicationFormSubmissionDto;
use App\Repository\ApplicationFormSubmissionRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* A form submitted on one of the public websites and posted here by TYPO3 - job applications
* first, other "collect it and let a human read it" forms later. Unrelated to
* App\Entity\Application, which is a teamer applying for an assignment.
*
* Deliberately neither Blameable nor SoftDeletable: nothing in this application creates these
* rows, and the retention rule for job applications asks for real deletion, so this is the one
* entity here that gets an actual DELETE. A purge has to cover "details" and "payload" as
* well - a json column is exactly what a deletion job forgets.
*
* The typed columns are the mapped core agreed with the TYPO3 side and all nullable: a form is
* configured without a field as often as with it. "details" is everything else the visitor
* filled in, keyed by the German field label in the order the form asked - display only, never
* a schema, never queried on (see .ddev/plans/MYEP-APPLICATION-ENDPOINT.md §3.3).
*/
#[ORM\Entity(repositoryClass: ApplicationFormSubmissionRepository::class)]
// What a list and a dedupe check ask for: the submissions of one form, newest first.
#[ORM\Index(name: 'IDX_F47CE995B5265E72', columns: ['form', 'submitted_at'])]
class ApplicationFormSubmission implements TimestampableEntityInterface
{
use TimestampableEntity;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\Column(length: 64)]
private string $form;
#[ORM\Column(length: 255)]
private string $formLabel;
#[ORM\Column]
private \DateTimeImmutable $submittedAt;
#[ORM\Column(nullable: true)]
private ?int $pageUid = null;
#[ORM\Column(length: 2048, nullable: true)]
private ?string $pageUrl = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $firstName = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $lastName = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $email = null;
#[ORM\Column(length: 64, nullable: true)]
private ?string $phone = null;
#[ORM\Column(length: 64, nullable: true)]
private ?string $mobile = null;
/**
* A string, not a date: the forms use a text field with a regex rather than a date picker,
* so this is whatever the visitor typed - usually dd.mm.yyyy. Parse it for display, store
* what arrived.
*/
#[ORM\Column(length: 64, nullable: true)]
private ?string $birthDate = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $street = null;
#[ORM\Column(length: 32, nullable: true)]
private ?string $zipCode = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $city = null;
/**
* Everything the visitor filled in beyond the mapped core, keyed by the German field label.
* Display only: the labels are staff-written and change on a reword, so nothing may query
* this, build a column from it or migrate old rows to a new spelling (§3.3).
*
* Note the labels come back in MySQL's order, not the form's - a json column normalises an
* object by sorting its keys by length. Render it in whatever order suits the reviewer; the
* form's own order is still in $payload, which is read as one blob and never sorted by us.
*
* @var array<string, string|bool|list<string>>
*/
#[ORM\Column(type: Types::JSON)]
private array $details = [];
/**
* The request body as received. Mapped keys this application does not know yet are ignored
* on the way in (§3.2) and the TYPO3 side widens a form's mapping without a release here,
* so without this copy those answers would be lost for good instead of backfillable.
*
* @var array<string, mixed>
*/
#[ORM\Column(type: Types::JSON)]
private array $payload = [];
public function __construct(string $form, string $formLabel, \DateTimeImmutable $submittedAt)
{
$this->uuid = Uuid::v4();
$this->form = $form;
$this->formLabel = $formLabel;
$this->submittedAt = $submittedAt;
}
/**
* The validated payload as a row. Called with a DTO that passed validation - submittedAt is
* parseable by then, which is why the constructor can insist on a date.
*
* The raw payload is passed in separately because the DTO no longer has it: the resolver
* drops every key the DTO has no property for, and those are the mapped fields the TYPO3
* side has added and this application does not know yet (§3.2). Keeping the body is what
* makes them backfillable instead of lost.
*
* @param array<string, mixed> $payload
*/
public static function fromDto(ApplicationFormSubmissionDto $dto, array $payload = []): self
{
$submission = new self($dto->form, $dto->formLabel, $dto->getSubmittedAt());
return $submission
->setPageUid($dto->getPageUid())
->setPageUrl($dto->pageUrl)
->setFirstName($dto->firstName)
->setLastName($dto->lastName)
->setEmail($dto->email)
->setPhone($dto->phone)
->setMobile($dto->mobile)
->setBirthDate($dto->birthDate)
->setStreet($dto->street)
->setZipCode($dto->zipCode)
->setCity($dto->city)
->setDetails($dto->getNormalizedDetails())
->setPayload($payload)
;
}
public function getId(): ?int
{
return $this->id;
}
public function getUuid(): string
{
return $this->uuid;
}
public function getForm(): string
{
return $this->form;
}
public function getFormLabel(): string
{
return $this->formLabel;
}
public function getSubmittedAt(): \DateTimeImmutable
{
return $this->submittedAt;
}
public function getPageUid(): ?int
{
return $this->pageUid;
}
public function setPageUid(?int $pageUid): static
{
$this->pageUid = $pageUid;
return $this;
}
public function getPageUrl(): ?string
{
return $this->pageUrl;
}
public function setPageUrl(?string $pageUrl): static
{
$this->pageUrl = $pageUrl;
return $this;
}
public function getFirstName(): ?string
{
return $this->firstName;
}
public function setFirstName(?string $firstName): static
{
$this->firstName = $firstName;
return $this;
}
public function getLastName(): ?string
{
return $this->lastName;
}
public function setLastName(?string $lastName): static
{
$this->lastName = $lastName;
return $this;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(?string $email): static
{
// Same normalization as Contact and Embeddable\Communication: a later match against
// existing people has to compare like with like.
$this->email = null === $email ? null : mb_strtolower(trim($email));
return $this;
}
public function getPhone(): ?string
{
return $this->phone;
}
public function setPhone(?string $phone): static
{
$this->phone = $phone;
return $this;
}
public function getMobile(): ?string
{
return $this->mobile;
}
public function setMobile(?string $mobile): static
{
$this->mobile = $mobile;
return $this;
}
public function getBirthDate(): ?string
{
return $this->birthDate;
}
public function setBirthDate(?string $birthDate): static
{
$this->birthDate = $birthDate;
return $this;
}
public function getStreet(): ?string
{
return $this->street;
}
public function setStreet(?string $street): static
{
$this->street = $street;
return $this;
}
public function getZipCode(): ?string
{
return $this->zipCode;
}
public function setZipCode(?string $zipCode): static
{
$this->zipCode = $zipCode;
return $this;
}
public function getCity(): ?string
{
return $this->city;
}
public function setCity(?string $city): static
{
$this->city = $city;
return $this;
}
/**
* @return array<string, string|bool|list<string>>
*/
public function getDetails(): array
{
return $this->details;
}
/**
* @param array<string, string|bool|list<string>> $details
*/
public function setDetails(array $details): static
{
$this->details = $details;
return $this;
}
/**
* @return array<string, mixed>
*/
public function getPayload(): array
{
return $this->payload;
}
/**
* @param array<string, mixed> $payload
*/
public function setPayload(array $payload): static
{
$this->payload = $payload;
return $this;
}
}
@@ -36,6 +36,13 @@ class AccessDeniedListener
return; return;
} }
// The api firewall is stateless and answers machines: a redirect to a login page would
// be read as success there, and the flash message below needs a session this request
// has none of.
if (str_starts_with($request->getPathInfo(), '/api/')) {
return;
}
$this->logger->warning('Access denied', [ $this->logger->warning('Access denied', [
'uri' => $request->getRequestUri(), 'uri' => $request->getRequestUri(),
]); ]);
+242
View File
@@ -0,0 +1,242 @@
<?php
namespace App\Model;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/**
* One submitted TYPO3 form, mapped from the request body by #[MapRequestPayload].
*
* Two rules from the contract (.ddev/plans/MYEP-APPLICATION-ENDPOINT.md §3.2, §3.3) shape this
* class and must survive any rewrite:
*
* - **Unknown keys are ignored, never rejected.** The mapped vocabulary grows on the TYPO3
* side without a release here, so nothing may switch the serializer's ALLOW_EXTRA_ATTRIBUTES
* off: it would turn every later mapping change into a 400, silent because the finisher only
* logs it.
* - **Everything but the four basics is optional.** A form is configured without a field as
* often as with it, so an absent key means "not part of this form", never "empty" - hence the
* defaults, which also let the serializer construct the object from a payload of four keys.
*
* The lengths are the storage limits of ApplicationFormSubmission, not an opinion about what a
* visitor may type: they exist so an oversized value is a 400 the TYPO3 side logs, and not a
* database error after the response has been promised.
*/
class ApplicationFormSubmissionDto
{
public const MAX_DETAIL_LABEL_LENGTH = 255;
public const MAX_DETAIL_VALUE_LENGTH = 65535;
/**
* @param array<string, mixed> $details
*/
public function __construct(
#[Assert\NotBlank(message: 'form must not be empty.')]
#[Assert\Length(max: 64, maxMessage: 'form must not exceed {{ limit }} characters.')]
public readonly ?string $form = null,
#[Assert\NotBlank(message: 'formLabel must not be empty.')]
#[Assert\Length(max: 255, maxMessage: 'formLabel must not exceed {{ limit }} characters.')]
public readonly ?string $formLabel = null,
#[Assert\NotBlank(message: 'submittedAt must not be empty.')]
public readonly ?string $submittedAt = null,
// int|string because a page id is unmistakable as "1421": rejecting the quoted form
// would cost a whole submission over a distinction nobody can see in a form editor.
// Read it back through getPageUid().
public readonly int|string|null $pageUid = null,
#[Assert\Length(max: 2048, maxMessage: 'pageUrl must not exceed {{ limit }} characters.')]
public readonly ?string $pageUrl = null,
#[Assert\Length(max: 255)]
public readonly ?string $firstName = null,
#[Assert\Length(max: 255)]
public readonly ?string $lastName = null,
#[Assert\Email(message: 'email is not a valid email address.')]
#[Assert\Length(max: 255)]
public readonly ?string $email = null,
#[Assert\Length(max: 64)]
public readonly ?string $phone = null,
#[Assert\Length(max: 64)]
public readonly ?string $mobile = null,
#[Assert\Length(max: 64)]
public readonly ?string $birthDate = null,
#[Assert\Length(max: 255)]
public readonly ?string $street = null,
#[Assert\Length(max: 32)]
public readonly ?string $zipCode = null,
#[Assert\Length(max: 255)]
public readonly ?string $city = null,
public readonly array $details = [],
) {
}
/**
* The value is generated with \DateTimeInterface::ATOM on the TYPO3 side, but it is still
* outside input: it is validated here rather than trusted, and read back through
* getSubmittedAt() so the endpoint never stores a date it could not parse.
*/
#[Assert\Callback]
public function validateSubmittedAt(ExecutionContextInterface $executionContext): void
{
if (null === $this->submittedAt || '' === $this->submittedAt) {
return;
}
if (null === $this->getSubmittedAt()) {
$executionContext
->buildViolation('submittedAt is not a valid ISO 8601 date and time.')
->atPath('submittedAt')
->addViolation()
;
}
}
#[Assert\Callback]
public function validatePageUid(ExecutionContextInterface $executionContext): void
{
if (null === $this->pageUid || '' === $this->pageUid) {
return;
}
if (null === $this->getPageUid()) {
$executionContext
->buildViolation('pageUid must be a positive integer.')
->atPath('pageUid')
->addViolation()
;
}
}
/**
* details is the only half with no schema: the keys are staff-written German field labels,
* so anything a person can type is a legal key - dots, slashes, umlauts and typos included.
* Only the shape is checked, because that is all the json column and the display can carry:
* a label with content, and a scalar or a flat list as the answer.
*/
#[Assert\Callback]
public function validateDetails(ExecutionContextInterface $executionContext): void
{
foreach ($this->details as $label => $value) {
$label = (string) $label;
if ('' === trim($label) || mb_strlen($label) > self::MAX_DETAIL_LABEL_LENGTH) {
$this->violate(
$executionContext,
sprintf('details contains an empty or overlong label ("%s").', mb_substr($label, 0, 32))
);
continue;
}
if (is_array($value) && false === array_is_list($value)) {
$this->violate($executionContext, sprintf('The value of "%s" must be a flat list.', $label));
continue;
}
foreach (is_array($value) ? $value : [$value] as $single) {
if (null === $single || is_scalar($single)) {
if (is_string($single) && mb_strlen($single) > self::MAX_DETAIL_VALUE_LENGTH) {
$this->violate(
$executionContext,
sprintf('The value of "%s" exceeds %d characters.', $label, self::MAX_DETAIL_VALUE_LENGTH)
);
}
continue;
}
$this->violate(
$executionContext,
sprintf('The value of "%s" must be a string, a boolean or a list of strings.', $label)
);
}
}
}
public function getPageUid(): ?int
{
if (null === $this->pageUid) {
return null;
}
if (is_string($this->pageUid) && 1 !== preg_match('/^\d+$/', $this->pageUid)) {
return null;
}
$pageUid = (int) $this->pageUid;
return $pageUid > 0 ? $pageUid : null;
}
public function getSubmittedAt(): ?\DateTimeImmutable
{
if (null === $this->submittedAt || '' === $this->submittedAt) {
return null;
}
// Anchored on a date: \DateTimeImmutable happily reads "now" or "monday", and a
// submission timestamped by a relative expression is not a submission time at all.
if (1 !== preg_match('/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/', $this->submittedAt)) {
return null;
}
try {
return new \DateTimeImmutable($this->submittedAt);
} catch (\Exception) {
return null;
}
}
/**
* The blob as it is stored: numbers arrive as strings from the form editor but nothing stops
* a mapping from sending one unquoted, and an empty answer is dropped rather than kept -
* TYPO3 strips those before sending (§3.3), so a label present in the blob always has
* content. The received order is the form's order and is never touched.
*
* @return array<string, string|bool|list<string>>
*/
public function getNormalizedDetails(): array
{
$normalized = [];
foreach ($this->details as $label => $value) {
if (null === $value || '' === $value || [] === $value) {
continue;
}
$normalized[(string) $label] = is_array($value)
? array_values(array_map($this->normalizeListValue(...), $value))
: $this->normalizeValue($value);
}
return $normalized;
}
private function violate(ExecutionContextInterface $executionContext, string $message): void
{
$executionContext
->buildViolation($message)
->atPath('details')
->addViolation()
;
}
private function normalizeValue(mixed $value): string|bool
{
return is_bool($value) ? $value : (string) $value;
}
/**
* A multi-select answer is a list of the labels the visitor picked, never a list of
* true/false: casting keeps the blob readable as what was chosen.
*/
private function normalizeListValue(mixed $value): string
{
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
return (string) $value;
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Repository;
use App\Entity\ApplicationFormSubmission;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ApplicationFormSubmission>
*
* @method ApplicationFormSubmission|null find($id, $lockMode = null, $lockVersion = null)
* @method ApplicationFormSubmission|null findOneBy(array $criteria, array $orderBy = null)
* @method ApplicationFormSubmission[] findAll()
* @method ApplicationFormSubmission[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class ApplicationFormSubmissionRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ApplicationFormSubmission::class);
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Security\AccessToken;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Http\AccessToken\AccessTokenExtractorInterface;
/**
* Reads the API key of the stateless "api" firewall from its own header. The built-in header
* extractor expects "Authorization: Bearer", which would claim a shared secret is an OAuth
* access token - this application is the client of a real OAuth provider (MYEP_OAUTH2_*), and
* the two must not look alike in a request.
*/
class ApiKeyExtractor implements AccessTokenExtractorInterface
{
public const HEADER = 'X-Api-Key';
public function extractAccessToken(Request $request): ?string
{
$key = trim((string) $request->headers->get(self::HEADER, ''));
return '' === $key ? null : $key;
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Security\AccessToken;
use Psr\Log\LoggerInterface;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
/**
* Validates the shared key a sibling website posts with. There is no person to log in and no
* account to look up: the key is the identity, and the user it resolves to is the in-memory
* "api" user of the api_user_provider - nothing about it may reach the user table, where a row
* would be a login to this application's UI.
*/
class ApiKeyTokenHandler implements AccessTokenHandlerInterface
{
public const USER_IDENTIFIER = 'api';
public function __construct(
private readonly string $apiKey,
private readonly LoggerInterface $logger,
) {
}
public function getUserBadgeFrom(string $accessToken): UserBadge
{
// An unconfigured key locks the endpoint instead of opening it: hash_equals('', '')
// is true, so without this check a deployment that forgot the secret would accept
// an empty key from anyone.
if ('' === $this->apiKey) {
$this->logger->error('API key authentication attempted while no key is configured');
throw new BadCredentialsException('No API key configured.');
}
if (false === hash_equals($this->apiKey, $accessToken)) {
$this->logger->warning('API key authentication failed');
throw new BadCredentialsException('Invalid API key.');
}
return new UserBadge(self::USER_IDENTIFIER);
}
}
@@ -0,0 +1,365 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Api;
use App\Entity\ApplicationFormSubmission;
use App\Repository\ApplicationFormSubmissionRepository;
use App\Security\AccessToken\ApiKeyExtractor;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Response;
/**
* The cases here are the ones the TYPO3 side actually produces
* (.ddev/plans/MYEP-APPLICATION-ENDPOINT.md §8): a payload of nothing but the basics, labels
* with slashes and umlauts, a long textarea answer, a duplicate label, and a mapped key this
* application does not know - which must not be a 400, or a later mapping change on the
* website silently stops every submission of that form.
*/
class ApplicationFormControllerTest extends WebTestCase
{
private const URL = '/api/application-forms';
private const KEY = 'test-application-form-api-key';
private KernelBrowser $client;
protected function setUp(): void
{
// access_control requires https for every path, so an http request is a 301 here
$this->client = static::createClient(server: ['HTTPS' => true]);
$this->entityManager()->createQuery(
sprintf('DELETE FROM %s', ApplicationFormSubmission::class)
)->execute();
}
public function testAKeylessRequestIsRejected(): void
{
$this->post(['form' => 'applicationform'], key: null);
$this->assertSame(Response::HTTP_UNAUTHORIZED, $this->client->getResponse()->getStatusCode());
$this->assertSame(0, $this->countSubmissions());
}
public function testAWrongKeyIsRejected(): void
{
$this->post(['form' => 'applicationform'], key: 'not-the-key');
$this->assertSame(Response::HTTP_UNAUTHORIZED, $this->client->getResponse()->getStatusCode());
}
public function testAFullSubmissionIsStored(): void
{
$this->post([
'form' => 'applicationform',
'formLabel' => 'Bewerbungsformular',
'submittedAt' => '2026-09-22T12:30:00+02:00',
'pageUid' => 1421,
'pageUrl' => 'https://www.ep-reisen.de/jobs/bewerbung',
'firstName' => 'Anna',
'lastName' => 'Muster',
'email' => '[email protected]',
'phone' => '+49 30 1234567',
'mobile' => '+49 170 1234567',
'birthDate' => '01.01.1990',
'street' => 'Musterstraße 1',
'zipCode' => '10115',
'city' => 'Berlin',
'details' => [
'Nationalität' => 'deutsch',
'Ausbildung/Studium' => 'Tourismusmanagement',
'Bewerbung als' => ['Reiseleitung', 'Skilehrer'],
],
]);
$this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode());
$submission = $this->lastSubmission();
$this->assertSame('applicationform', $submission->getForm());
$this->assertSame('Bewerbungsformular', $submission->getFormLabel());
$this->assertSame('2026-09-22T12:30:00+02:00', $submission->getSubmittedAt()->format(\DateTimeInterface::ATOM));
$this->assertSame(1421, $submission->getPageUid());
$this->assertSame('01.01.1990', $submission->getBirthDate());
$this->assertSame('[email protected]', $submission->getEmail(), 'the address is normalized like everywhere else');
$this->assertSame(['Reiseleitung', 'Skilehrer'], $submission->getDetails()['Bewerbung als']);
}
/**
* A form may map nothing but the basics, and an absent mapped key means the form has no such
* field - never an empty value that failed validation.
*/
public function testTheBasicsAloneAreEnough(): void
{
$this->post([
'form' => 'contactform',
'formLabel' => 'Kontaktformular',
'submittedAt' => '2026-09-22T12:30:00+02:00',
'details' => [],
]);
$this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode());
$submission = $this->lastSubmission();
$this->assertNull($submission->getFirstName());
$this->assertNull($submission->getEmail());
$this->assertSame([], $submission->getDetails());
}
/**
* The mapped vocabulary grows on the website side without a release here. An unknown key is
* ignored, and kept in the raw payload so it can be mapped later without losing the answers
* that arrived in the meantime.
*/
public function testAnUnknownMappedKeyIsIgnoredAndKept(): void
{
$this->post([
'form' => 'applicationform',
'formLabel' => 'Bewerbungsformular',
'submittedAt' => '2026-09-22T12:30:00+02:00',
'nationality' => 'deutsch',
'details' => [],
]);
$this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode());
$this->assertSame('deutsch', $this->lastSubmission()->getPayload()['nationality']);
}
/**
* A duplicate label is disambiguated by the identifier on the TYPO3 side, so both answers
* survive as separate keys (§3.3). The order is MySQL's, not the form's - a json column
* sorts an object's keys by length - so only the set of labels is pinned here.
*/
public function testEveryLabelSurvivesIncludingADisambiguatedDuplicate(): void
{
$details = [
'Nationalität' => 'deutsch',
'Führerschein' => 'ja',
'Einsatz datum' => 'ab 20.12.2026',
'Geburtsdatum' => '01.01.1990',
'Geburtsdatum (text-10)' => '02.02.1992',
];
$this->post([
'form' => 'applicationform',
'formLabel' => 'Bewerbungsformular',
'submittedAt' => '2026-09-22T12:30:00+02:00',
'details' => $details,
]);
$this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode());
$stored = $this->lastSubmission()->getDetails();
$this->assertEqualsCanonicalizing(array_keys($details), array_keys($stored));
$this->assertSame('01.01.1990', $stored['Geburtsdatum']);
$this->assertSame('02.02.1992', $stored['Geburtsdatum (text-10)']);
}
public function testALongAnswerIsStored(): void
{
$answer = str_repeat('ä', 3000);
$this->post([
'form' => 'applicationform',
'formLabel' => 'Bewerbungsformular',
'submittedAt' => '2026-09-22T12:30:00+02:00',
'details' => ['Warum ich' => $answer],
]);
$this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode());
$this->assertSame($answer, $this->lastSubmission()->getDetails()['Warum ich']);
}
public function testAPayloadWithoutPageInformationIsAccepted(): void
{
$this->post([
'form' => 'applicationform',
'formLabel' => 'Bewerbungsformular',
'submittedAt' => '2026-09-22T12:30:00+02:00',
'details' => ['Nationalität' => 'deutsch'],
]);
$this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode());
$this->assertNull($this->lastSubmission()->getPageUid());
$this->assertNull($this->lastSubmission()->getPageUrl());
}
/**
* pageUid arrives as a JSON number today. A numeric string is pinned here because the
* distinction is invisible in a form editor and nobody would notice it becoming a 400.
*/
public function testANumericStringPageUidIsAccepted(): void
{
$this->post([
'form' => 'applicationform',
'formLabel' => 'Bewerbungsformular',
'submittedAt' => '2026-09-22T12:30:00+02:00',
'pageUid' => '1421',
'details' => [],
]);
$this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode());
$this->assertSame(1421, $this->lastSubmission()->getPageUid());
}
public function testABodyWithoutAJsonContentTypeIsRejected(): void
{
$this->client->request(
'POST',
self::URL,
server: ['HTTP_'.strtoupper(str_replace('-', '_', ApiKeyExtractor::HEADER)) => self::KEY],
content: json_encode([
'form' => 'applicationform',
'formLabel' => 'Bewerbungsformular',
'submittedAt' => '2026-09-22T12:30:00+02:00',
'details' => [],
], JSON_THROW_ON_ERROR)
);
$this->assertSame(0, $this->countSubmissions());
// Pinned because it is what the TYPO3 side has to be told: the resolver needs the
// header to read the body as JSON at all, and answers 400 without it.
$this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode());
}
public function testABrokenBodyIsRejected(): void
{
$this->client->request(
'POST',
self::URL,
server: ['CONTENT_TYPE' => 'application/json', 'HTTP_'.strtoupper(str_replace('-', '_', ApiKeyExtractor::HEADER)) => self::KEY],
content: '{"form": "applicationform",'
);
$this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode());
$this->assertSame(0, $this->countSubmissions());
}
public function testAMissingFormIsRejected(): void
{
$this->post([
'formLabel' => 'Bewerbungsformular',
'submittedAt' => '2026-09-22T12:30:00+02:00',
'details' => [],
]);
$this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode());
$this->assertSame(0, $this->countSubmissions());
}
public function testAnUnparseableSubmissionTimeIsRejected(): void
{
$this->post([
'form' => 'applicationform',
'formLabel' => 'Bewerbungsformular',
'submittedAt' => 'gestern',
'details' => [],
]);
$this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode());
$this->assertSame(0, $this->countSubmissions());
}
public function testAnInvalidEmailAddressIsRejected(): void
{
$this->post([
'form' => 'applicationform',
'formLabel' => 'Bewerbungsformular',
'submittedAt' => '2026-09-22T12:30:00+02:00',
'email' => 'not-an-address',
'details' => [],
]);
$this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode());
}
public function testANestedDetailValueIsRejected(): void
{
$this->post([
'form' => 'applicationform',
'formLabel' => 'Bewerbungsformular',
'submittedAt' => '2026-09-22T12:30:00+02:00',
'details' => ['Bewerbung als' => ['erste' => ['Reiseleitung']]],
]);
$this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode());
$this->assertSame(0, $this->countSubmissions());
}
public function testTheEndpointOnlyAnswersPost(): void
{
$this->client->request('GET', self::URL, server: [
'HTTP_'.strtoupper(str_replace('-', '_', ApiKeyExtractor::HEADER)) => self::KEY,
]);
$this->assertSame(Response::HTTP_METHOD_NOT_ALLOWED, $this->client->getResponse()->getStatusCode());
}
/**
* §5.1 is not implemented yet: a double-clicked submit button stores two rows for now, and
* this pins that it is a known state rather than an accident.
*/
public function testTheSamePayloadTwiceIsStoredTwiceForNow(): void
{
$payload = [
'form' => 'applicationform',
'formLabel' => 'Bewerbungsformular',
'submittedAt' => '2026-09-22T12:30:00+02:00',
'email' => '[email protected]',
'details' => [],
];
$this->post($payload);
$this->post($payload);
$this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode());
$this->assertSame(2, $this->countSubmissions());
}
/**
* @param array<string, mixed> $payload
*/
private function post(array $payload, ?string $key = self::KEY): void
{
$server = ['CONTENT_TYPE' => 'application/json'];
if (null !== $key) {
$server['HTTP_'.strtoupper(str_replace('-', '_', ApiKeyExtractor::HEADER))] = $key;
}
$this->client->request('POST', self::URL, server: $server, content: json_encode($payload, JSON_THROW_ON_ERROR));
}
private function lastSubmission(): ApplicationFormSubmission
{
$this->entityManager()->clear();
/** @var ApplicationFormSubmissionRepository $repository */
$repository = $this->entityManager()->getRepository(ApplicationFormSubmission::class);
$submission = $repository->findOneBy([], ['id' => 'DESC']);
$this->assertInstanceOf(ApplicationFormSubmission::class, $submission);
return $submission;
}
private function countSubmissions(): int
{
return (int) $this->entityManager()
->createQuery(sprintf('SELECT COUNT(s.id) FROM %s s', ApplicationFormSubmission::class))
->getSingleScalarResult()
;
}
private function entityManager(): EntityManagerInterface
{
return static::getContainer()->get(EntityManagerInterface::class);
}
}
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Tests\Model;
use App\Model\ApplicationFormSubmissionDto;
use PHPUnit\Framework\TestCase;
/**
* The normalization the json column relies on: no empty answers, numbers as strings, booleans
* kept, and a multi-select as a flat list of what was picked.
*/
class ApplicationFormSubmissionDtoTest extends TestCase
{
public function testEmptyAnswersAreDropped(): void
{
$dto = $this->dto(['Nationalität' => 'deutsch', 'Führerschein' => '', 'Sprachen' => [], 'Sport' => null]);
$this->assertSame(['Nationalität' => 'deutsch'], $dto->getNormalizedDetails());
}
public function testNumbersBecomeStringsAndBooleansStay(): void
{
$dto = $this->dto(['Skifahrer seit' => 2004, 'Datenschutz' => true]);
$this->assertSame(['Skifahrer seit' => '2004', 'Datenschutz' => true], $dto->getNormalizedDetails());
}
public function testAMultiSelectStaysAFlatListOfLabels(): void
{
$dto = $this->dto(['Ich fahre' => ['Ski', 'Snowboard']]);
$this->assertSame(['Ich fahre' => ['Ski', 'Snowboard']], $dto->getNormalizedDetails());
}
public function testARelativeSubmissionTimeIsNotADate(): void
{
$this->assertNull((new ApplicationFormSubmissionDto(submittedAt: 'now'))->getSubmittedAt());
$this->assertNull((new ApplicationFormSubmissionDto(submittedAt: '2026-09-22'))->getSubmittedAt());
$this->assertNotNull((new ApplicationFormSubmissionDto(submittedAt: '2026-09-22T12:30:00+02:00'))->getSubmittedAt());
}
/**
* @param array<string, mixed> $details
*/
private function dto(array $details): ApplicationFormSubmissionDto
{
return new ApplicationFormSubmissionDto(
form: 'applicationform',
formLabel: 'Bewerbungsformular',
submittedAt: '2026-09-22T12:30:00+02:00',
details: $details,
);
}
}