Skip to content

fix: return clean 404 instead of crashing on POST without a trailing slash - #1807

Merged
kriszyp merged 4 commits into
mainfrom
fix/post-trailing-slash-clean-error
Jul 21, 2026
Merged

fix: return clean 404 instead of crashing on POST without a trailing slash#1807
kriszyp merged 4 commits into
mainfrom
fix/post-trailing-slash-clean-error

Conversation

@kriszyp

@kriszyp kriszyp commented Jul 14, 2026

Copy link
Copy Markdown
Member

Summary

POSTing to a Resource path missing its required trailing slash (or hitting it with a trailing slash plus a nonexistent path segment) threw an unhandled TypeError (Cannot read properties of undefined/null (reading 'query')) instead of a clean HTTP error. The trailing-slash requirement itself is correct, documented, intended behavior — this only fixes the crash.

Root cause: Resources.getMatch() returns an empty relativeURL ('') when a URL matches a resource's exact base path with no trailing slash. new RequestTarget('') then hit an early return meant only for the truly argument-less internal constructor call (new RequestTarget()), leaving both id and isCollection undefined — a state distinct from (and easy to confuse with) the well-formed collection target (id: null, isCollection: true) that a real trailing slash produces. Any dispatch/permission code downstream that assumed one of the two was always set could crash on the undefined value.

Changes

  • resources/RequestTarget.ts: the "matched, nothing left to parse, no trailing slash" case now gets its own well-defined state (id: null, isCollection: false) instead of leaving both undefined.
  • resources/Resource.ts: the shared transactional() dispatch wrapper rejects that state for POST up front with a clear 404, before any (possibly custom) post()/allowCreate() override ever sees the half-resolved target.
  • resources/Table.ts: updated the GET "describe" check (GET /Table with no trailing slash returns table metadata) from target.id === undefined to target.id == null to match the new invariant — this was already existing, intentional behavior that needed to stay working.
  • unitTests/resources/post-trailing-slash.test.js: new coverage for the RequestTarget invariant plus both failure shapes from the issue, and regression tests confirming well-formed POST/GET (collection and non-collection) are unaffected.

Investigation note

I could not reproduce the exact original crash on current main with a bare table or with the real customer-facing pattern this issue names (super.post(data) dropping the target argument, from template-redirector-3.0.1.tgz's Version class) — missingMethod()'s existing 405 already catches those cases cleanly. The underlying RequestTarget state bug is real and independently confirmed (verified the undefined-vs-well-formed asymmetry directly), and is the most plausible root cause given the issue's exact "undefined" vs "null" error-shape distinction between the two malformed-path variants. This fix closes that gap at the dispatch layer so it can't crash any current or future resource implementation, rather than relying on missingMethod's incidental safety net.

Review notes

Ran a cross-model review (Gemini via agy, standard mode). It raised three concerns, all verified as false positives against the actual code (Gemini only sees the diff, not full file context):

  • Claimed the Table.ts describe-check change breaks collection reads — disproven: isSearchTarget() already routes isCollection: true targets to search() before the describe check runs (now covered by an explicit regression test).
  • Claimed PUT/PATCH/DELETE to the same malformed path hit the same crash — disproven empirically: they already return a clean 400 Invalid primary key via existing checkValidId validation, unaffected by this change.
  • Claimed options.method could be uppercase, bypassing the new guard — disproven: options.method is a hardcoded lowercase literal in each static verb's own declaration (e.g. method: 'post'), not derived from the request's raw HTTP method casing.

Refs #678

🤖 Generated with Claude Code

kriszyp and others added 2 commits July 14, 2026 15:20
…slash

RequestTarget('') (an exact resource-path match with nothing left to
parse, i.e. no trailing slash) left both `id` and `isCollection`
undefined instead of the well-defined null/false Resources.getMatch()
produces for every other match shape. Any dispatch or authorization
code that assumed one of the two was always set (rather than reading
a truly `undefined` value) could throw an unhandled TypeError instead
of a clean HTTP error.

- RequestTarget: give the "matched but no trailing slash" case its
  own well-defined state (id=null, isCollection=false) instead of
  leaving both undefined.
- Resource.ts: reject that state for POST up front with a clear 404,
  before any post()/allowCreate() override runs against it.
- Table.ts: update the GET "describe" check to the new null-based
  signal so `GET /resource` (no trailing slash) still returns table
  metadata instead of regressing to 404.

Refs #678

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Cross-model review flagged the Table.ts describe-check change as a
risk to collection reads; verified it's a false positive (isSearchTarget
already routes isCollection=true targets to search() before the describe
check runs) and added an explicit regression test for it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request resolves an issue where POSTing to a resource path without a trailing slash resulted in an unhandled TypeError. It ensures that RequestTarget properly defines id and isCollection as null and false respectively when there is no trailing slash, and updates Resource.ts to throw a clean 404 error for such POST requests. Additionally, Table.ts is updated to use a loose nullish check for target.id, and new unit tests are introduced. The review feedback suggests using node:assert/strict instead of node:assert in the new test file to align with strict equality standards.

Comment thread unitTests/resources/post-trailing-slash.test.js Outdated
@claude

claude Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

kriszyp and others added 2 commits July 14, 2026 15:53
…st()

The clean-404 guard added for harper#678 fired for every Resource subclass's
static POST dispatch, including ones with their own instance post() override
(e.g. the redirector template's Redirect.post(), which does a bulk CSV/JSON
import via POST to its collection root with no trailing slash — a legitimate,
currently-supported convention). That regressed all 6 CI runtime legs on
integrationTests/components/redirector.test.ts.

Root cause: the guard didn't need to be this broad. It exists to give a purpose
-built error message ahead of the base Resource.prototype.post()'s fallback to
missingMethod() (which already throws a clean, if generic, 405 for this same
null-id/non-collection state -- confirmed by reverting the whole harper#678 fix
and re-running post-trailing-slash.test.js: no crash, just a 405 instead of the
desired 404). A resource with its own post() override is trusted to handle a
null-id/non-collection target itself; it may not consult id/isCollection at all.

Now only throws when this.prototype.post === Resource.prototype.post, i.e. the
resource has no custom post() to hand the request to.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Per gemini-code-assist review on #1807: strict-mode assertions align
with the current testing standard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@kriszyp
kriszyp marked this pull request as ready for review July 15, 2026 12:51
@kriszyp
kriszyp requested a review from DavidCockerill July 15, 2026 12:51
@kriszyp
kriszyp merged commit aaa517e into main Jul 21, 2026
54 checks passed
@kriszyp
kriszyp deleted the fix/post-trailing-slash-clean-error branch July 21, 2026 02:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant