fastify/fast-json-stringify

Returns "[object Object]" when toJSON() returns a non object type (e.g. string, number)

bmenant opened this issue · 4 comments

Prerequisites

  • I have written a descriptive issue title
  • I have searched existing issues to ensure the bug has not already been reported

Fastify version

3.27.4

Plugin version

No response

Node.js version

v16.14.0

Operating system

Linux

Operating system version (i.e. 20.04, 11.3, 10)

5.17.5-200.fc35.x86_64

Description

From what we’ve seen so far, fast-json-stringify is unable to properly stringify an object with a toJSON() method returning a primitive type.

To give some background: we noticed the issue while we were working on a restful API with Fastify. The project has Domain-driven design (DDD) flavors, with Entities, ValueObjects, etc. We sometime want to get a Data transfer object (DTO) from an Entity and simply go with it in the API response, since the DTO is pretty much aligned to the API response’s JsonSchema.

We later reproduced the issue with a minimal example (below).

Is this a known issue? Is there a workaround (other than fastStringify(JSON.parse(JSON.stringify(aggregate))))?

Steps to Reproduce

  1. Install this project.
  2. Add the following test in test/toJSON.test.js:
test('use toJSON method on deep object types', (t) => {
  t.plan(2)

  const stringify = build({
    title: 'simple object',
    type: 'object',
    properties: {
      name: {
        type: 'string',
      }
    },
  })
  const aggregate = {
    name: {
      toJSON() {
        return 'whatever'
      }
    }
  }
  const expected = '{"name":"whatever"}';

  t.equal(JSON.stringify(aggregate), expected);
  t.equal(stringify(aggregate), expected);
});
  1. Run npx tap test/toJSON.test.js

result-json

Expected Behavior

We expected fast-json-stringify to be on par with the native JSON.stringify() function regarding toJSON().

The project has DDD flavors, with Entities, ValueObjects, etc. We sometime want to get a DTO from an Entity and simply go with it in the API response, since the DTO is pretty much aligned to the API response’s JsonSchema.

Please do not use abbreviations without spelling them out first. It is not clear what you are saying here.

I'm not sure your object is adding the toJSON method in the correct location. It should be the top-level object that implements that method. The following code returns the same JSON for both stringification methods:

const fjs = require('fast-json-stringify')
const stringify = fjs({
  title: 'simple object',
  type: 'object',
  properties: {
    name: { type: 'string' }
  }
})

const obj = {
  name () {
    return 'foo'
  },
  toJSON() {
    return { name: this.name() }
  }
}

console.log(JSON.stringify(obj))
console.log(stringify(obj))

Please do not use abbreviations without spelling them out first. It is not clear what you are saying here.

Sorry, my bad! I (wrongly) thought Domain-driven design (DDD) and Data transfer object (DTO) were well-known abbreviations. I’ve updated the description.

Your answer is a valid alternative indeed (and certainly advisable to enforce strong separation of concern in every domain boundary).

However, the native JSON.stringify is recursive (minus the circular references errors) and, as far as I know, the specifications don’t mention that only the top-level toJSON caller is honored.

Also, there are cases where it looks a little cumbersome to redo the serialization (i.e. JSON~ification) of say shared valueObjects (e.g. Date, PhoneNumber, etc.) in every root-aggregate they’re to be found and re-used.

For example, consider the following aggregate:

MedicalAppointment
  [...]
  Patient
    [...]
    PhoneNumber
  Doctor
    [...]
    PhoneNumber
  AmbulanceDriver
    [...]
    PhoneNumber

We certainly have logic in PhoneNumber value objects but they’re likely to serialize the same way for Patient, Doctor and Ambulance Driver. Actually, if there exists a standard format for phone number representation, it is likely that our PhoneNumber objects would serialize the same way anywhere else (or, at least, it’d give a sensible default).

Also, there might be some places where we need to serialize the Doctor entity alone, and some other places where we need the whole MedicalAppointment aggregate to be serialized. If only top-level toJSON were honored, we’d somehow have to redo the serialization of PhoneNumber objects in multiple places, whereas the native JSON.serialize and toJSON provide a standard mechanism for (deep) objects serialization.


Nevertheless, as experimented in #414, I came to the conclusion this feature might be out of scope for this project. I close this ticket as a known limitation.

@bmenant I hope this issue gets resolved by #424. We need to add some changes before that.

@bmenant simultaneously should do the trick as a workaround 👍 :

'use strict'

const test = require('tap').test
const build = require('..')

test('use toJSON method on deep object types', (t) => {
  t.plan(2)

  const stringify = build({
    title: 'simple object',
    type: 'object',
    properties: {
      name: {
        simultaneously: {
          type: ["object", "string"],
        },
      },
    },
  })
  const aggregate = {
    name: {
      toJSON() {
        return 'whatever'
      }
    }
  }
  const expected = '{"name":"whatever"}';

  t.equal(JSON.stringify(aggregate), expected);
  t.equal(stringify(aggregate), expected);
});