PerlDancer / PerlDancer/Dancer2

Serializing a non-reference route return value depends on the installed JSON encoder version

Open
#1,803 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Perl
Stars
604
Forks
288
Avg merge
1d 5h
Merged PRs (30d)
5

Description

Summary

What Dancer2 does with a route that returns a plain, non-reference value under a serializer depends on which version of the JSON encoder is installed. The same application, same Dancer2, answers differently on two machines:

installed encoder Content-Type body
Cpanel::JSON::XS 4.37 text/html (empty)
Cpanel::JSON::XS 4.43 application/json "plain string"

Not two different backends — the same backend, two versions. RFC 8259 permits any value at the top level of a JSON document, where the older RFC 4627 required an object or array, and Cpanel::JSON::XS changed to match. (I have not pinned the exact release that changed; 4.37 refuses and 4.43 accepts.)

This surfaced as a test that passed on one machine and failed on another with no code change between them.

Reproduction

use strict; use warnings;
{ package P; use Dancer2; set logger => 'null'; set serializer => 'JSON';
  get '/hash'    => sub { { a => 1 } };
  get '/string'  => sub { 'plain string' };
  get '/zero'    => sub { 0 };
  get '/zerostr' => sub { '0' };
  get '/empty'   => sub { '' };
}
use Plack::Test; use HTTP::Request::Common;
my $t = Plack::Test->create(P->to_app);
for my $p (qw(/hash /string /zero /zerostr /empty)) {
    my $r = $t->request(GET $p);
    printf "%-10s %-18s %s\n", $p, ($r->header('Content-Type')//'-'), "'".($r->content//'')."'";
}

With an encoder that refuses a top-level non-reference:

/hash      application/json   '{"a":1}'
/string    text/html          ''
/zero      text/html          ''
/zerostr   text/html          ''
/empty     text/html          ''

With an encoder that accepts one:

/hash      application/json   '{"a":1}'
/string    application/json   '"plain string"'
/zero      text/html          ''
/zerostr   text/html          ''
/empty     text/html          ''

(The second table was produced locally by forcing an encoder whose default accepts a
top-level non-reference; the /string row is what Cpanel::JSON::XS 4.43 was directly
observed to do. The falsy rows are identical under both and were observed under both.)

Mechanism

Three things combine.

1. We never ask for a policy. Dancer2::Serializer::JSON::serialize builds the encoder without allow_nonref, so whether a top-level scalar is legal is left entirely to the installed module's default — which is exactly the thing that changed:

JSON::MaybeXS->new($options)->encode($entity);

2. The failure is swallowed. When the encoder does refuse, it dies, and the around serialize in Dancer2::Core::Role::Serializer catches that and logs it rather than propagating (unless strict_utf8 is set):

} or do {
    my $error = $@ || 'Zombie Error';
    if ( blessed($self) && $self->config->{strict_utf8} ) { die $error }
    blessed $self and $self->log_cb->( core => "Failed to serialize content: $error" );
};

So the route still returns 200. Dancer2::Core::Response::serialize then bails before assigning the content type:

$content = $serializer->serialize($content) or return;
$self->content_type( $serializer->content_type );

The response keeps the default text/html and an empty body. The value the application returned is discarded with nothing to show for it but a log line.

3. Falsy content never reaches the serializer at all. Independently of the above, both guards are truth tests rather than definedness tests — $content or return $content in the role, and the or return in Response::serialize. So 0, '0' and '' are dropped under both encoders, as the table shows. JSON has perfectly good representations for all three. A route ending in return 0; silently produces an empty text/html response.

Worth noting that the second guard is a hazard even for the accepting case: an encoder that accepts non-refs encodes 0 to the string "0", which is itself falsy in Perl, so it would bail at or return even if it got that far.

Why this matters

  • The same code gives different HTTP responses depending on a transitive dependency's version, with nothing in the application or in Dancer2 to indicate it.
  • Both behaviours are defensible in isolation, but neither is documented, so an upgrade of an unrelated module silently changes an API's output.
  • The swallowing case loses data with only a log line, and returns 200 while doing it — a client sees a successful, empty, text/html response where it asked for JSON.

Possible directions

Not proposing a fix, just laying out what seems available:

  1. Decide and pin it. Pass allow_nonref explicitly, one way or the other, so behaviour stops tracking the dependency. Consistent, but changes behaviour for somebody either way.
  2. Refuse loudly. If a non-reference is not something we intend to serialize, say so rather than returning an empty 200 — the current silence is the worst part regardless of which encoder is installed.
  3. Handle falsy content properly. Switch the two or return guards to definedness tests, so 0 and '' serialize like any other value. Arguably a bug fix independent of the rest of this.
  4. Document it. At minimum, state in Dancer2::Serializer::JSON what happens to a non-reference return value, and that it depends on the installed encoder.

Happy to work up whichever direction is preferred. My own inclination is (3) as a straightforward fix, plus (1) or (2) as a deliberate decision, with (4) either way.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with Dancer2::Serializer::JSON::serialize, the around serialize behavior in Dancer2::Core::Role::Serializer, and Dancer2::Core::Response::serialize. Reproduce the listed hash, scalar, and falsy-value cases with different encoder behavior, then confirm the intended policy with maintainers. Done means an agreed behavior is implemented, documented, and covered by regression tests.

Written by the indexing model from the issue text.

Assessment

Tech stack
perl
Domain
api, backend
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Needs clarification
Newbie friendliness
42/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.