Skip to content

fix(http): treat an explicit null optional param as omitted - #327

Merged
ChiragAgg5k merged 6 commits into
mainfrom
fix/http-null-optional-default
Sep 23, 2026
Merged

ChiragAgg5k merged 6 commits into
mainfrom
fix/http-null-optional-default

Conversation

@ChiragAgg5k

@ChiragAgg5k ChiragAgg5k commented Sep 23, 2026 •

Copy link
Copy Markdown
Member

Why

An optional param sent as an explicit null skips validation and reaches the action as null, even though the route declared a default. Every action with a typed signature then throws a TypeError, which surfaces as a 500. On Appwrite Cloud, POST /v1/messaging/messages/push with "image": null hit this ~130 times in two weeks (appwrite/appwrite#13849). Appwrite has been patching these one endpoint at a time by making the argument nullable and copying the default into the body ($path ??= '/', $image ??= ''), so each default lives in two places that can drift apart.

What

In Http::getArguments(), an explicit null for an optional param is treated exactly as if the param had been omitted, unless the param's validator accepts null (isValid(null)):

  • The action receives the declared default, which is not validated, the same as for an omitted param.
  • A validator that accepts null is how a route says null is a meaningful value (for example "clear this field"). Those params still receive null. This covers Nullable, compositions such as AnyOf containing a Nullable, a WhiteList that lists null, Wildcard, and validators built by a factory closure.
  • Params with a null default are unaffected.
  • Params declared with skipValidation: true are unaffected. Their validator is never built or called, and an explicit null reaches the action as before.

Validator factory resolution moved into a private resolveValidator() that is shared with validate().

Before / after

Http::post('/v1/messaging/messages/push')
    ->param('image', '', new CompoundUID(), 'Image', true)
    ->param('badge', -1, new Integer(), 'Badge', true)
    ->action(function (string $image, int $badge) { /* ... */ });
POST /v1/messaging/messages/push
Content-Type: application/json

{"image": null, "badge": null}

Before: TypeError: ...::action(): Argument #1 ($image) must be of type string, null given, returned as a 500.

After: the action runs with $image = '' and $badge = -1, the same as when both are omitted.

->param('x', 'x-def', new Nullable(new Text(200)), 'x', true) // null still reaches the action
->param('y', 'y-def', new AnyOf([new Nullable(new Text(200)), new Integer()]), 'y', true) // so does this

Impact on Appwrite (must land before bumping)

Two independent audits covered every ->param() in appwrite/appwrite:

  • 856 params are optional with a non-null default.
  • 793 of those bind to a non-nullable action argument. Today an explicit null there is a 500, so this change is a pure fix.
  • 56 accept null and either treat it like the default or crash on it. An example of the crash is site rule branch, where null fails the deployment query with a 500.

Seven params give an explicit null a meaning of its own. After this change they would receive the default instead:

Route Param null today With this change alone
PUT /v1/sites/:siteId providerRepositoryId keeps the VCS connection '' disconnects the repository
PUT /v1/storage/buckets/:bucketId maximumFileSize keeps the bucket value resets to the plan limit
PUT /v1/storage/buckets/:bucketId compression keeps the bucket value resets to none
PUT /v1/storage/buckets/:bucketId encryption keeps the bucket value forces true
PUT /v1/vectorsdb/:databaseId/collections/:collectionId enabled keeps a disabled collection disabled re-enables it
POST /v1/users password 400 password_personal_data when the personal-data policy is on creates a user with no password
POST /v1/messaging/messages/email attachments stored as null stored as []

appwrite/appwrite#13852 wraps each of these validators in Nullable and keeps its default. That preserves today's behaviour exactly, and the PR has e2e tests showing each one fails without the wrap and passes with it. It must merge together with the bump to the release containing this fix.

Cloud should be checked the same way before it picks this up. The first audit flagged that billing address addressLine2, state and postalCode would store "" instead of null.

Tests

testExplicitNullOptionalParamFallsBackToDefault covers these cases:

  • A typed action receives the defaults for explicit nulls, and the output is identical to omitting them. The '' default is not validated against WhiteList(['a', 'b']).
  • A validator that accepts null still receives null: Nullable given directly or from a factory closure, and AnyOf containing a Nullable.
  • A null default still yields null.
  • A param with skipValidation: true and a validator factory that throws still receives null, and the factory is never called. This fails against the first revision of this PR.

The test fails against the previous Http.php.

HttpTest now saves and restores $_GET/$_POST around each test. testCanHookThrowExceptions left $_GET['y'] set, which leaked into later tests.

bin/monorepo test http and bin/monorepo check http (Pint, PHPStan, Rector) pass.

A null skipped validation and bypassed the declared default, so typed actions threw a TypeError and every endpoint had to restore its defaults by hand. A Nullable validator still receives null, since that is how a route declares null as a value.
@greptile-apps

greptile-apps Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 4/5

The runtime change appears sound, but the implementation-coupled test violates an explicit repository requirement and should be corrected before merging.

Fix All in Claude CodeFindings

  1. P2 Test Enforces Implementation Detail ▶
Fix with agent prompt
### Issue 1
packages/http/tests/HttpTest.php:1005-1007
This throwing factory makes whether `resolveValidator()` is called part of the test contract. Building an unused validator could preserve all observable request behavior, so this assertion would reject a valid refactor without detecting a user-visible regression. The repository requires tests to verify observable behavior rather than mirror source internals, and that requirement must be satisfied before merging.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

This PR changes HTTP argument extraction so an explicit null for an optional parameter falls back to its declared non-null default unless its validator accepts null.

  • Extracts validator-factory resolution into a shared helper.
  • Preserves explicit null for nullable validators, null defaults, and parameters that skip validation.
  • Adds request-global restoration and explicit-null behavior coverage.

Reviews (3) · Last reviewed commit: "fix(http): leave params that skip valida..."

Comment thread packages/http/src/Http/Http.php Outdated
@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Benchmark results

http — Swoole modes (4 cores, 200 VUs, 20s/run)

workload mode req/s p95
ok a 26135.819553/s 17.1ms
ok b 30377.703228/s 14.79ms
io a 455.018506/s 902.94ms
io b 3360.736116/s 51.2ms
cpu a 4289.667943/s 129.38ms
cpu b 4121.580168/s 62.05ms

a = HYPERLOOP_A (process), b = HYPERLOOP_B (coroutine)

Shared CI runners — treat absolute numbers as rough, compare modes within a run. Commit 19b1de7.

A top-level Nullable check missed compositions such as AnyOf containing a Nullable, replacing a meaningful null with the default. Asking the validator whether null is valid covers every composition.
The explicit-null check built and ran the validator before looking at skipValidation, so a skipped validator factory ran, or threw, on requests that used to succeed. Such params now receive null exactly as before.
Comment thread packages/http/tests/HttpTest.php Outdated
The regression test pinned whether the validator factory runs. What callers observe is the value the action receives, so assert that instead.
@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

✅ OpenCodeReview: Review complete: 0 finding(s) across 2 selected item(s).

Reviewed 60353b5..963b304 only; earlier commits in this PR were reviewed in a previous run.

Comment thread packages/http/src/Http/Http.php Outdated
A path value wins over a request null for the same key, so building the validator to test null there was wasted work and ran stateful factories an extra time.
@ChiragAgg5k

Copy link
Copy Markdown
Member Author

@greptile review

@walter-o-brien walter-o-brien Bot 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.

✅ OpenCodeReview found no issues in 963b304 and has no open findings on this pull request.

This is an automated review and does not replace a review by a maintainer.

@ChiragAgg5k

Copy link
Copy Markdown
Member Author

@greptile review

@ChiragAgg5k
ChiragAgg5k merged commit 48bf71c into main Sep 23, 2026
7 checks passed
@ChiragAgg5k
ChiragAgg5k deleted the fix/http-null-optional-default branch September 23, 2026 13:53
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.

2 participants