Background
Found while auditing the adapters for v3 preconditions.
#251 (closed) fixed "a non-empty body with a non-JSON Content-Type is silently treated as absent". Both adapters carry docblocks citing #251, so both look fixed. Only one is.
Current behaviour
Laravel — src/Laravel/ValidatesOpenApiSchema.php:1481-1483 preserves the wire-presence bit:
if ($content !== '' || $request->request->all() !== [] || $request->files->all() !== []) {
return DecodedBody::present(null);
}
return DecodedBody::absent();
Symfony — extractSymfonyRequestBody() (src/Symfony/OpenApiAssertions.php:457-474) handles form media types, then delegates everything else to extractSymfonyJsonBody(), which at :502-506 has no presence branch:
// Non-JSON Content-Type: leave the body undecoded and report it absent.
// ... (issue #251)
if ($contentType !== '' && !ContentTypeMatcher::isJsonContentType(
ContentTypeMatcher::normalizeMediaType($contentType),
)) {
return DecodedBody::absent();
}
src/Validation/Request/RequestBodyValidator.php branches on that bit in three places — :152, :270, :347 — all spelled if ($required && !$requestBody->present).
Problem
Same request, two verdicts:
|
Content-Type: application/xml, non-empty body, requestBody.required: true |
| Laravel adapter |
present(null) → $required && !present is false → passes |
| Symfony adapter |
absent() → $required && !present is true → fails with "Request body is empty" |
A contract-testing tool that returns a different verdict for the same request depending on which adapter the suite happens to use is reporting the framework, not the contract. There is no pin test on the Symfony side for this case.
What to do
Move the presence rule to one place and have both adapters use it. src/Symfony/HttpFoundationFormBody.php:25-29 is the existing precedent for an @internal helper shared by both HttpFoundation adapters, so it is the natural home.
Converge on the Laravel rule (presence preserved). That direction turns a current Symfony failure into a pass, which is safe for a minor; the reverse would break passing suites.
One constraint on the shared helper: the failure channel must be a thrown JsonException that each trait wraps, not a direct failOpenApi() call. The AssertionFailedError that failOpenApi() produces is caught by type in the baseline path from #402; a shared helper calling it directly would route Symfony failures into the Laravel-shaped handler.
Compatibility
Verdict change on the Symfony adapter, in the fail → pass direction. Ships as a v2 minor bug fix. Under ADR 0004 this must not be folded into a v3 reduction PR — it changes what Gesso decides about a request.
Acceptance criteria
Related
Background
Found while auditing the adapters for v3 preconditions.
#251 (closed) fixed "a non-empty body with a non-JSON Content-Type is silently treated as absent". Both adapters carry docblocks citing #251, so both look fixed. Only one is.
Current behaviour
Laravel —
src/Laravel/ValidatesOpenApiSchema.php:1481-1483preserves the wire-presence bit:Symfony —
extractSymfonyRequestBody()(src/Symfony/OpenApiAssertions.php:457-474) handles form media types, then delegates everything else toextractSymfonyJsonBody(), which at:502-506has no presence branch:src/Validation/Request/RequestBodyValidator.phpbranches on that bit in three places —:152,:270,:347— all spelledif ($required && !$requestBody->present).Problem
Same request, two verdicts:
Content-Type: application/xml, non-empty body,requestBody.required: truepresent(null)→$required && !presentis false → passesabsent()→$required && !presentis true → fails with "Request body is empty"A contract-testing tool that returns a different verdict for the same request depending on which adapter the suite happens to use is reporting the framework, not the contract. There is no pin test on the Symfony side for this case.
What to do
Move the presence rule to one place and have both adapters use it.
src/Symfony/HttpFoundationFormBody.php:25-29is the existing precedent for an@internalhelper shared by both HttpFoundation adapters, so it is the natural home.Converge on the Laravel rule (presence preserved). That direction turns a current Symfony failure into a pass, which is safe for a minor; the reverse would break passing suites.
One constraint on the shared helper: the failure channel must be a thrown
JsonExceptionthat each trait wraps, not a directfailOpenApi()call. TheAssertionFailedErrorthatfailOpenApi()produces is caught by type in the baseline path from #402; a shared helper calling it directly would route Symfony failures into the Laravel-shaped handler.Compatibility
Verdict change on the Symfony adapter, in the fail → pass direction. Ships as a v2 minor bug fix. Under ADR 0004 this must not be folded into a v3 reduction PR — it changes what Gesso decides about a request.
Acceptance criteria
Requestwith a non-empty, non-JSON, non-form body against an operation declaringrequestBody.required: truecurrently fails and must pass@internalhelper used by both HttpFoundation adaptersAssertionFailedErrorfrom the Laravel sidestr_contains(strtolower($contentType), 'json')/ContentTypeMatcher::isJsonContentType()split noted in tech-debt(adapters) — 非空・非JSON Content-Type のボディが silent に「ボディ無し」扱いされる #251 stays converged onContentTypeMatchercomposer cipassesRelated
null/ scalar is silently treated as "no body" #246 — theDecodedBodyenvelope this relies on