feat: accept form submissions from the websites over an API endpoint
This commit is contained in:
1 parent
f48e53fdd0
commit
30ad5e8488
17 files changed
+1350
-4
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user