Skip to content

feat: validated request parameters, so malformed input answers 400 instead of 500 - #19

Merged
tuj merged 3 commits into
developfrom
feature/8000-input-validation
Aug 15, 2026
Merged

feat: validated request parameters, so malformed input answers 400 instead of 500#19
tuj merged 3 commits into
developfrom
feature/8000-input-validation

Conversation

@tuj

@tuj tuj commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Link to ticket

https://leantime.itkdev.dk/#/tickets/showTicket/8000

Description

  • Validated request parameters, so malformed input answers 400 with a reason instead of failing with a 500.
  • Rejected a limit below 1, which previously dropped the LIMIT clause and returned every row, and capped limit at 1000.
  • Accepted comma separated ids, projectIds and types, since the endpoints are documented as GET with query parameters.
  • Defaulted types to all supported types on the deleted endpoint, and stopped an unknown type reaching the error page.

Checklist

  • My code is covered by test cases.
  • My code passes our test (all our tests).
  • My code passes our static analysis suite.
  • My code passes our continuous integration process.

@tuj tuj self-assigned this Jul 30, 2026
@tuj tuj added the enhancement New feature or request label Jul 30, 2026
@tuj tuj changed the title Feature/8000 input validation feat: validated request parameters, so malformed input answers 400 instead of 500 Jul 30, 2026
@tuj
tuj changed the base branch from develop to feature/8000-sync-errors July 30, 2026 09:00
@tuj
tuj requested a review from turegjorup August 4, 2026 06:36

@turegjorup turegjorup left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved with comments. Below is the issues Claude found. I don't consider them blockers, but there are some inconsistencies that it would be nice to clean up, like the differences in how an empty array is handled in 1). Also the change in the bahavior in the deleted endpoint, to now default to everything.

These might be necessary and intentional for compatibility reasons, but might also be simple omissions, I don't have the full picture on the Economics (client) side.

1. projectIds: [] bypasses the filter entirely — Repositories/ApiDataRepository.php:40,57,75

The PR now formally supports and tests empty-list semantics (testAnEmptyIdsArrayStaysAnEmptyFilter — "keeps the existing behaviour of matching no rows"). That holds for ids, which uses !== null:

->when($ids !== null, fn ($query) => $query->whereIn("ticket.id", $ids))       // [] → WHERE 0 = 1 → no rows
->when($projectIds != null, fn ($query) => $query->whereIn(..., $projectIds))  // [] == null → filter skipped → ALL rows

RequestParameters::toIds([]) returns [], and [] != null is false in PHP. So POST {"projectIds": []} — reachable through the documented body form — returns rows from every project instead of none. Opposite of what the caller asked for. Three !=!== fixes it, and this PR is the natural place since it just codified the empty-list contract.

2. The deleted endpoint has no cap, and its default request is now the largest one

Defaulting types to all four (Model/DeletedRequestParameters.php:67) is right for the 500, but getDeleted takes no limit and deleted is optional — so a bare GET /apidata/api/deleted now scans four tables and returns every deleted id ever recorded. Previously that request did nothing. The PR caps entity endpoints at MAX_LIMIT = 1000 for exactly this reason; the deleted endpoint is left unbounded. Not a blocker, but worth a follow-up issue at minimum.

3. Nit: array elements aren't trimmed, comma-string elements are — Model/CoercesRequestInput.php:53-60

?types=tickets,%20timesheets works (trimmed); ?types[]=tickets%20 is a 400, because a type is matched with a strict in_array(). Cheap to align by trimming scalars in the array branch too. Note this only bites typesids survives either form, since is_numeric() accepts leading and (as of PHP 8.0) trailing whitespace.

Comment thread Model/InvalidRequestException.php Outdated
* so the message reaches the client — name the parameter and the expected shape,
* never the value that was sent.
*/
class InvalidRequestException extends \InvalidArgumentException {}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we rename to BadRequestException to match HTTP spec terminology? https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/400

tuj added 2 commits August 11, 2026 14:13
An empty projectIds list was compared loosely, so it dropped the filter and
answered with every row instead of none, while an empty ids list correctly
matched nothing.

Elements sent in array form are now trimmed like the comma separated form, so
?types[]=tickets%20 is no longer a 400 against the strict type comparison.

types is required on the deleted endpoint, missing or empty. The endpoint has
no limit, so defaulting it let a bare request return every deleted id ever
recorded, and an empty list would answer 200 with nothing, which a sync client
reads as "nothing was deleted".

Renamed InvalidRequestException to BadRequestException, matching the 400 it
turns into.
@tuj
tuj requested a review from turegjorup August 14, 2026 09:11
Base automatically changed from feature/8000-sync-errors to develop August 15, 2026 13:56
@tuj
tuj merged commit d226c3a into develop Aug 15, 2026
3 checks passed
@tuj
tuj deleted the feature/8000-input-validation branch August 15, 2026 13:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants