Skip to content

bug(symfony): #251's body-presence fix landed on the Laravel adapter only, so the same request passes on Laravel and fails on Symfony #550

Description

@wadakatu

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

Laravelsrc/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();

SymfonyextractSymfonyRequestBody() (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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions