Skip to content

Commit 42d5750

Browse files
committed
Fixed merge
2 parents 1f1f3c3 + d226c3a commit 42d5750

10 files changed

Lines changed: 613 additions & 33 deletions

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@
99
* Changed the deletion triggers to stamp `dateDeleted` in UTC, so `deleted` filters against the same clock the responses are read in.
1010
* Moved the schema handling into a SchemaRepository, executing one statement at a time so installation reports failures instead of swallowing them, and made installing idempotent.
1111
* Dropped the `dateDeleted` default on the deletion tables, so a row inserted without a trigger in place is left null rather than stamped with the server's local time.
12+
* [PR-19](https://github.com/ITK-Leantime/data-api/pull/19)
13+
* Validated request parameters, so malformed input answers 400 with a reason instead of failing with a 500.
14+
* Rejected a limit below 1, which previously dropped the LIMIT clause and returned every row, and capped limit at 1000.
15+
* Accepted comma separated ids, projectIds and types, since the endpoints are documented as GET with query parameters.
16+
* Required types on the deleted endpoint, so a bare request answers 400 instead of returning every deleted id ever recorded, and stopped an unknown type reaching the error page.
17+
* Fixed an empty projectIds list dropping the filter, which answered with every row instead of none.
18+
* Trimmed whitespace around ids, projectIds and types elements sent in array form.
19+
* Renamed InvalidRequestException to BadRequestException, matching the 400 it turns into.
1220
* [PR-18](https://github.com/ITK-Leantime/data-api/pull/18)
1321
* Allowed null values in API models, so entries referencing deleted users or deleted tickets no longer fail the whole request.
1422
* Added userId to timesheets, so hours logged by a deleted user stay attributable.

Controllers/API.php

Lines changed: 33 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
namespace Leantime\Plugins\APIData\Controllers;
44

55
use Leantime\Core\Controller\Controller;
6+
use Leantime\Plugins\APIData\Model\BadRequestException;
7+
use Leantime\Plugins\APIData\Model\DeletedRequestParameters;
8+
use Leantime\Plugins\APIData\Model\RequestParameters;
69
use Leantime\Plugins\APIData\Model\ResponseData;
710
use Leantime\Plugins\APIData\Services\APIData;
811
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -21,78 +24,80 @@ public function init(APIData $dataAPIService): void
2124

2225
public function deleted(array $input): JsonResponse
2326
{
24-
return new JsonResponse($this->getDeleted($input));
27+
return $this->respond(fn () => $this->getDeleted($input));
2528
}
2629

2730
public function projects(array $input): JsonResponse
2831
{
29-
return new JsonResponse($this->getResults($input, APIData::TYPE_PROJECTS));
32+
return $this->respond(fn () => $this->getResults($input, APIData::TYPE_PROJECTS));
3033
}
3134

3235
public function milestones(array $input): JsonResponse
3336
{
34-
return new JsonResponse($this->getResults($input, APIData::TYPE_MILESTONES));
37+
return $this->respond(fn () => $this->getResults($input, APIData::TYPE_MILESTONES));
3538
}
3639

3740
public function tickets(array $input): JsonResponse
3841
{
39-
return new JsonResponse($this->getResults($input, APIData::TYPE_TICKETS));
42+
return $this->respond(fn () => $this->getResults($input, APIData::TYPE_TICKETS));
4043
}
4144

4245
public function timesheets(array $input): JsonResponse
4346
{
44-
return new JsonResponse($this->getResults($input, APIData::TYPE_TIMESHEETS));
47+
return $this->respond(fn () => $this->getResults($input, APIData::TYPE_TIMESHEETS));
4548
}
4649

4750
public function workers(array $input): JsonResponse
4851
{
49-
return new JsonResponse($this->getResults($input, APIData::TYPE_WORKERS));
52+
return $this->respond(fn () => $this->getResults($input, APIData::TYPE_WORKERS));
53+
}
54+
55+
/**
56+
* A parameter the caller got wrong is their error, not ours, so it answers
57+
* 400 with the reason instead of Leantime's 500 error page.
58+
*/
59+
private function respond(callable $resolve): JsonResponse
60+
{
61+
try {
62+
return new JsonResponse($resolve());
63+
} catch (BadRequestException $exception) {
64+
return new JsonResponse(['error' => $exception->getMessage()], JsonResponse::HTTP_BAD_REQUEST);
65+
}
5066
}
5167

5268
private function getDeleted(array $input): array
5369
{
54-
$types = $input['types'];
55-
$deleted = $input['deleted'] ?? null;
70+
$parameters = DeletedRequestParameters::fromInput($input);
5671

5772
$deletedEntries = [];
5873
$count = 0;
5974

60-
foreach ($types as $type) {
61-
$deletedEntries[$type] = $this->dataAPIService->getDeleted($type, $deleted);
75+
foreach ($parameters->types as $type) {
76+
$deletedEntries[$type] = $this->dataAPIService->getDeleted($type, $parameters->deleted);
6277
$count = $count + count($deletedEntries[$type]);
6378
}
6479

6580
return (new ResponseData(
66-
['types' => $types],
81+
$parameters->toArray(),
6782
$count,
6883
$deletedEntries,
6984
))->toArray();
7085
}
7186

7287
private function getResults(array $input, string $type): array
7388
{
74-
$start = (int) ($input['start'] ?? 0);
75-
$limit = (int) ($input['limit'] ?? 100);
76-
$modifiedAfter = $input['modifiedAfter'] ?? null;
77-
$ids = $input['ids'] ?? null;
78-
$projectIds = $input['projectIds'] ?? null;
89+
$parameters = RequestParameters::fromInput($input);
7990

8091
$results = match ($type) {
81-
APIData::TYPE_PROJECTS => $this->dataAPIService->getProjects($start, $limit, $modifiedAfter, $ids),
82-
APIData::TYPE_MILESTONES => $this->dataAPIService->getMilestones($start, $limit, $modifiedAfter, $ids, $projectIds),
83-
APIData::TYPE_TICKETS => $this->dataAPIService->getTickets($start, $limit, $modifiedAfter, $ids, $projectIds),
84-
APIData::TYPE_TIMESHEETS => $this->dataAPIService->getTimesheets($start, $limit, $modifiedAfter, $ids, $projectIds),
85-
APIData::TYPE_WORKERS => $this->dataAPIService->getWorkers($start, $limit, $modifiedAfter, $ids),
92+
APIData::TYPE_PROJECTS => $this->dataAPIService->getProjects($parameters->start, $parameters->limit, $parameters->modifiedAfter, $parameters->ids),
93+
APIData::TYPE_MILESTONES => $this->dataAPIService->getMilestones($parameters->start, $parameters->limit, $parameters->modifiedAfter, $parameters->ids, $parameters->projectIds),
94+
APIData::TYPE_TICKETS => $this->dataAPIService->getTickets($parameters->start, $parameters->limit, $parameters->modifiedAfter, $parameters->ids, $parameters->projectIds),
95+
APIData::TYPE_TIMESHEETS => $this->dataAPIService->getTimesheets($parameters->start, $parameters->limit, $parameters->modifiedAfter, $parameters->ids, $parameters->projectIds),
96+
APIData::TYPE_WORKERS => $this->dataAPIService->getWorkers($parameters->start, $parameters->limit, $parameters->modifiedAfter, $parameters->ids),
8697
};
8798

8899
return (new ResponseData(
89-
[
90-
'start' => $start,
91-
'limit' => $limit,
92-
'modifiedAfter' => $modifiedAfter,
93-
'ids' => $ids,
94-
'projectIds' => $projectIds,
95-
],
100+
$parameters->toArray(),
96101
count($results),
97102
$results,
98103
))->toArray();

Model/BadRequestException.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
namespace Leantime\Plugins\APIData\Model;
4+
5+
/**
6+
* A request parameter the caller can fix. The controller turns this into a 400,
7+
* so the message reaches the client — name the parameter and the expected shape,
8+
* never the value that was sent.
9+
*/
10+
class BadRequestException extends \InvalidArgumentException {}

Model/CoercesRequestInput.php

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<?php
2+
3+
namespace Leantime\Plugins\APIData\Model;
4+
5+
/**
6+
* Shared coercions for the request parameter objects. Everything arrives as a
7+
* string when the endpoints are called with query parameters, so each value has
8+
* to be narrowed explicitly rather than cast — a plain (int) cast turns "abc"
9+
* into 0, which silently means "from the beginning of time" for a timestamp.
10+
*/
11+
trait CoercesRequestInput
12+
{
13+
private static function toNonNegativeInt(mixed $value, string $name): int
14+
{
15+
if (!is_int($value) && !(is_string($value) && is_numeric($value))) {
16+
throw new BadRequestException(sprintf('%s must be a whole number.', $name));
17+
}
18+
19+
if ((float) $value !== (float) (int) $value) {
20+
throw new BadRequestException(sprintf('%s must be a whole number.', $name));
21+
}
22+
23+
if ((int) $value < 0) {
24+
throw new BadRequestException(sprintf('%s cannot be negative.', $name));
25+
}
26+
27+
return (int) $value;
28+
}
29+
30+
private static function toTimestamp(mixed $value, string $name): ?int
31+
{
32+
if ($value === null || $value === '') {
33+
return null;
34+
}
35+
36+
return self::toNonNegativeInt($value, $name);
37+
}
38+
39+
/**
40+
* The README documents GET with query parameters, so a list commonly arrives
41+
* as "1,2,3" rather than ids[]=1&ids[]=2. An empty array is kept as an empty
42+
* list — the caller asked for a list containing nothing — while an empty
43+
* string means the parameter was never really sent.
44+
*
45+
* @return list<mixed>|null
46+
*/
47+
private static function toList(mixed $value, string $name): ?array
48+
{
49+
if ($value === null) {
50+
return null;
51+
}
52+
53+
if (is_array($value)) {
54+
$elements = [];
55+
56+
foreach ($value as $element) {
57+
if (!is_scalar($element)) {
58+
throw new BadRequestException(sprintf('%s must be a list of values.', $name));
59+
}
60+
61+
// Trimmed like the comma separated form, so ?types[]=tickets%20
62+
// is not a 400 while ?types=tickets,%20timesheets works. Only
63+
// strings, to leave a JSON body's integers as integers.
64+
$elements[] = is_string($element) ? trim($element) : $element;
65+
}
66+
67+
return $elements;
68+
}
69+
70+
if (!is_scalar($value)) {
71+
throw new BadRequestException(sprintf('%s must be a list of values.', $name));
72+
}
73+
74+
$elements = array_filter(
75+
array_map('trim', explode(',', (string) $value)),
76+
fn ($element) => $element !== '',
77+
);
78+
79+
return [] === $elements ? null : array_values($elements);
80+
}
81+
}

Model/DeletedRequestParameters.php

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
<?php
2+
3+
namespace Leantime\Plugins\APIData\Model;
4+
5+
use Leantime\Plugins\APIData\Services\APIData;
6+
7+
/**
8+
* The parameters for the deleted-entities endpoint. `types` is required: the
9+
* endpoint has no limit, so each type returns its whole deletion history, and
10+
* defaulting it would let a bare request scan every table. An unknown value used
11+
* to reach the repository's match arm, which reflected the raw input into the
12+
* error page.
13+
*/
14+
readonly class DeletedRequestParameters
15+
{
16+
use CoercesRequestInput;
17+
18+
/**
19+
* @param list<string> $types
20+
*/
21+
public function __construct(
22+
public array $types,
23+
public ?int $deleted,
24+
) {}
25+
26+
/**
27+
* @param array<string, mixed> $input
28+
*/
29+
public static function fromInput(array $input): self
30+
{
31+
return new self(
32+
types: self::toTypes($input['types'] ?? null),
33+
deleted: self::toTimestamp($input['deleted'] ?? null, 'deleted'),
34+
);
35+
}
36+
37+
/**
38+
* @return array<string, mixed>
39+
*/
40+
public function toArray(): array
41+
{
42+
return [
43+
'types' => $this->types,
44+
'deleted' => $this->deleted,
45+
];
46+
}
47+
48+
/**
49+
* @return list<string>
50+
*/
51+
public static function supportedTypes(): array
52+
{
53+
return [
54+
APIData::TYPE_PROJECTS,
55+
APIData::TYPE_MILESTONES,
56+
APIData::TYPE_TICKETS,
57+
APIData::TYPE_TIMESHEETS,
58+
];
59+
}
60+
61+
/**
62+
* @return list<string>
63+
*/
64+
private static function toTypes(mixed $value): array
65+
{
66+
$types = self::toList($value, 'types');
67+
68+
// An empty list is rejected along with a missing one: it would otherwise
69+
// answer 200 with nothing, which a caller reads as "nothing was deleted".
70+
if ($types === null || $types === []) {
71+
throw new BadRequestException(sprintf(
72+
'types is required and must contain at least one of: %s.',
73+
implode(', ', self::supportedTypes()),
74+
));
75+
}
76+
77+
foreach ($types as $type) {
78+
if (!in_array($type, self::supportedTypes(), true)) {
79+
throw new BadRequestException(sprintf(
80+
'types must only contain: %s.',
81+
implode(', ', self::supportedTypes()),
82+
));
83+
}
84+
}
85+
86+
return array_values(array_unique($types));
87+
}
88+
}

0 commit comments

Comments
 (0)