Repository navigation
fix(http): make request/response handling consistent end to end - #399
Conversation
abdulmajidcse
left a comment
There was a problem hiding this comment.
@techmahedy Tests and PHPStan pass, but I found these issues at ac2923f:
New in this PR
Request.php:249:array_merge($request, $data)renumbers and duplicates numeric keys.10=a&20=bbecomes["a","b","a","b"].array_replace()fixes it.Response.php:1586:toSerializable()callstoArray()on models and skips a customjsonSerialize(), which can expose hidden fields.Router.php:1198: theHEADmemo goes stale whengetOriginalClosure()reloads routes, so cached apps return 404 forHEADclosure routes.RequestParser.php:16: when trustedForwardedandX-Forwarded-Fordisagree,ip()throws a generic\Exception, then returns0.0.0.0on the next call.
Already in the base (not caused by this PR)
5. Router.php:1372: auth bypass. /admin/ reaches the /admin handler with no route middleware, because getCurrentRouteMiddleware() doesn't trim the trailing slash.
6. Request.php:1457: an invalid X-HTTP-Method-Override or _method throws from the constructor, outside the new try.
Smaller
7. Response.php:646: there is no 400.odo.php, so malformed JSON falls through to abort() instead of a clean 400.
8. RequestParser.php:27: the ips() docblock says the client is first, but the list is most-trusted first.
9. WebErrorRenderer.php:239: still hardcodes vendor/doppar/framework; it should use resolveErrorView().
10. Request.php:430: isValidRequest() now has no callers, so it should be removed or corrected.
|
Thanks @abdulmajidcse, great catches. All addressed in the next push:
There are 18 new regression tests; each fails on |
abdulmajidcse
left a comment
There was a problem hiding this comment.
@techmahedy Thanks, most of the fixes check out. Still open at 8d4283c:
Fix before merge
Response::dispatchHttpException(): now that400.odo.phpexists, malformed JSON on/apiroutes gets the HTML page instead of a JSON error. It renders the view without checking for API or AJAX requests.Request::getMethod():_method[]=PUTstill throws aTypeErrorfromstrtoupper(), becausevalidateMethodOverride()only catchesBadRequestHttpException. It should return a 400.
Worth fixing here
3. Model::toArray(): related models still skip a custom jsonSerialize(), so $user->posts can expose fields the override hides.
4. Request::getMethod() (also in the base): GET /posts/1?_method=DELETE reaches the DELETE handler. Overrides should only apply to real POST requests.
Separately, EncryptionTest::test_legacy_ciphertext_is_not_decrypted_to_garbage_by_the_other_key is flaky. This PR doesn't cause it, and it doesn't need to block the merge.
…and jsonSerialize for relations
|
Thanks @abdulmajidcse, all four are fixed in the next push:
There are 11 new tests; the 7 covering these fixes fail on |
abdulmajidcse
left a comment
There was a problem hiding this comment.
@techmahedy One more at 309ef40: Router::any() (Router.php:618) still reads $request->_method directly:
_method[]=PUTthrows aTypeErrorinaddRoute(). Routes load during boot, so this should happen before the 400 check inhandle().GET /any?_method=DELETEno longer matches: the route registers underDELETE, butgetMethod()now returnsGET.
Using $method = $request->getMethod(); fixes both.
|
Thanks @abdulmajidcse, fixed as suggested: Checked in a real app against
There are 3 new router tests, which fail on |
abdulmajidcse
left a comment
There was a problem hiding this comment.
@techmahedy The any() fix looks good. Two regressions from the base are left at 1bdafda:
Request::parseRequestBody(): query params are now merged into the request bag, soPOST /m?_method=DELETEbecomesDELETE, and?_method=garbagerecords a 400. Overrides should only come from the body:$query = $this->query->all(); unset($query['_method']); $request = $this->request->all() + $query;
ResponseFactory::make():make($response, 503, ['Retry-After' => '10'])now returns 200 with no header, because an existing response is returned as is:if ($content instanceof Response) { if ($status !== 200) { $content->setStatusCode($status); } return $content->withHeaders($headers); }
With both applied on a local copy, the suite and PHPStan pass. Neither behavior has a test yet, so please add regression tests.
…headers in make()
|
Thanks @abdulmajidcse, both applied as suggested:
|
|
@techmahedy Thanks, the latest fixes look good. We can merge this now. |
Pull Request Checklist
Description
An end-to-end review of the HTTP request/response path (Request → body parsing → Router → Response/Factory → stream, redirect and error responses). It fixes real bugs, removes duplicated and dead code, and makes
HEADrequests work onGETroutes.Bug fixes
Request
ip(),ips()andisValidRequest(). They read static properties through$this->trustedHeaderSet, which evaluates to0and emits a notice plus a warning on every call. They now use the proxy-awaregetClientIp()/getClientIps().ips()returned[]; it now returns the client IP first.scheme()ignoredX-Forwarded-Proto. It now usesgetScheme().getRouteParam()was a fatal error. It indexedParameterBagas an array.input()with no key returnednullinstead of all input. Its internal cache also went stale after direct changes to the request bag. The cache is removed.400 Bad Request. Before, it caused a PHP fatal and an empty 500, because the error handler calledrequest()and re-parsed the broken body.createFromGlobals()now keeps a usable request and stores the failure;Application::handle()raises it as aBadRequestHttpException.Response
setExceptionError()stored the status code instead of the exception.text()set the status code without the status text, sotext('x', 404)sent404 OK.ResponseFactory::text()modified a shared container instance; it now creates a new response.setContent()instead ofsetBody(), so stream callbacks ran on HEAD/204/304 responses.prepare()calledisHead('HEAD'), which takes no argument.headers_send()function.vendor/doppar/framework(for example a path repository). The lookup is now package-relative and shared byResponseandRequestAbortion.response()->json($queryBuilder)encoded the builder object itself rather than its results.Routing
HEADrequests toGET-only routes returned404. All route lookups (callback, middleware, current route name) now resolve a HEAD request to an explicitRoute::head()route if one matches, otherwise to theGETroute. The handler and its middleware always come from the same route, soauthand similar middleware still apply.HEADnever falls back toPOST,PUT,PATCHorDELETE.Duplication and dead code removed
Response::shouldBeJson()andResponse::toSerializable().ResponseFactoryandRoutereach had their own, slightly different copy.BaseJsonResponse.DEFAULT_ENCODING_OPTIONSnow has one source instead of three copies.prepareRequestUri,prepareBaseUrl,preparePathInfo,getUrlencodedPrefix,isIisRewriteandinitializeHeaders.Application::handle()and a deadis_array()guard inRule::sanitize().auth()now delegates touser().hasFiles()reuseshasFile().ip()/ips()andscheme()now delegate to the proxy-aware methods.withHeaders().Responsefacade docblock in valid@methodsyntax. This removed 51 PHPStan baseline suppressions.Breaking changes
ip()andscheme()return the real client values, andips()lists the client IP first.response($model)and otherJsonSerializablevalues now produce JSON. An object that implements onlyStringableis now sent as its string value.response()->redirect()returns aRedirectResponsewith a standard redirect body.POSTs now include query-string parameters inall().BaseJsonResponseStreamedResponse::DEFAULT_ENCODING_OPTIONSResponse::headers_send()$request->inputpropertysetContent()/getContent()on stream and file responses$excludeKeysargument offailed()setException()now requires aThrowableornull.HEADrequest to aGET-only route now returns that route's response instead of404.Checklist