fastify-type-provider-typebox icon indicating copy to clipboard operation
fastify-type-provider-typebox copied to clipboard

Schema generated with `Tuple<Union>` incompatible to `fast-json-stringify`

Open colin240215 opened this issue 1 year ago • 4 comments

Prerequisites

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

Fastify version

4.26.2

Plugin version

4.0.0

Node.js version

20.11.1

Operating system

Linux

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

Ubuntu 22.04.4

Description

Tuple<Union> leads to serialization error. The code below creates a fastify server with 3 endpoints. Each responds with exactly what it receives. Only the schemas are slightly different:

  • /array: acceptes Array<Union>
  • /unionTuple: accepts Tuple<Union>
  • /numTuple: accepts Tuple<Number>

One can reproduce the error by sending requests to /unionTuple with body [1, 2]. Everything is fine when sending the same request to the other 2 endpoints.

import fastify from 'fastify';
import { type TypeBoxTypeProvider } from '@fastify/type-provider-typebox';
import { Type } from '@sinclair/typebox';

const app = fastify(
  { ajv: { customOptions: { coerceTypes: false } } },
).withTypeProvider<TypeBoxTypeProvider>();

const StringOrNum = Type.Union([Type.String(), Type.Number()]);

const UnionArray = Type.Array(StringOrNum);
const ArraySchema = {
  body: UnionArray,
  response: {
    200: UnionArray,
  },
};
app.post('/array', { schema: ArraySchema }, (req, res) => {
  const array = req.body;
  res.status(200).send(array);
});

const UnionTuple = Type.Tuple([StringOrNum, StringOrNum]);
const UnionTupleSchema = {
  body: UnionTuple,
  response: {
    200: UnionTuple,
  },
};
app.post('/unionTuple', { schema: UnionTupleSchema }, (req, res) => {
  const tuple = req.body;
  res.status(200).send(tuple);
});

const NumTuple = Type.Tuple([Type.Number(), Type.Number()]);
const NumTupleSchema = {
  body: NumTuple,
  response: {
    200: NumTuple,
  },
};
app.post('/numTuple', { schema: NumTupleSchema }, (req, res) => {
  const tuple = req.body;
  res.status(200).send(tuple);
});

app.setErrorHandler((error, req, res) => {
  if ('serialization' in error) {
    res.status(500).send({ 'serialization error': error.message });
  } else {
    res.send(error);
  }
});

app.listen({ port: 3000 });

Link to code that reproduces the bug

No response

Expected Behavior

Type<Union> is expected to serialize the data successfully. The endpoint have identical schema for request and response. So if data format is invalid, some exception should be thrown upon request validation. But the error I encountered happens during serialization, so I suspect that it must have something to do with fast-json-stringify failing to serialize the response body.

colin240215 avatar May 07 '24 07:05 colin240215

After further investigation, it seems like the generated schema serializer has a bug at the 4th if statement in anonymous0(). Inside this statement is an if-else block. Apparently, it always executes the else block and throw Error('Item at 0 does not match schema definition.'). (because the corresponding if condition is undefined).

(function anonymous(validator, serializer) {
    const JSON_STR_BEGIN_OBJECT = "{";
    const JSON_STR_END_OBJECT = "}";
    const JSON_STR_BEGIN_ARRAY = "[";
    const JSON_STR_END_ARRAY = "]";
    const JSON_STR_COMMA = ",";
    const JSON_STR_COLONS = ":";
    const JSON_STR_QUOTE = '"';
    const JSON_STR_EMPTY_OBJECT = JSON_STR_BEGIN_OBJECT + JSON_STR_END_OBJECT;
    const JSON_STR_EMPTY_ARRAY = JSON_STR_BEGIN_ARRAY + JSON_STR_END_ARRAY;
    const JSON_STR_EMPTY_STRING = JSON_STR_QUOTE + JSON_STR_QUOTE;
    const JSON_STR_NULL = "null";

    function anonymous0(obj) {
        // #

        if (obj === null) return JSON_STR_EMPTY_ARRAY;
        if (!Array.isArray(obj)) {
            throw new TypeError(`The value of '#' does not match schema definition.`);
        }
        const arrayLength = obj.length;

        if (arrayLength > 2) {
            throw new Error(`Item at 2 does not match schema definition.`);
        }

        const arrayEnd = arrayLength - 1;
        let value;
        let json = "";
        value = obj[0];
        if (0 < arrayLength) {
            if (undefined) { // ====== ====== ====== problem here ====== ====== ======
                if (validator.validate("__fjs_root_3#/items/0/anyOf/0", value))
                    if (typeof value !== "string") {
                        if (value === null) {
                            json += JSON_STR_EMPTY_STRING;
                        } else if (value instanceof Date) {
                            json += JSON_STR_QUOTE + value.toISOString() + JSON_STR_QUOTE;
                        } else if (value instanceof RegExp) {
                            json += serializer.asString(value.source);
                        } else {
                            json += serializer.asString(value.toString());
                        }
                    } else {
                        json += serializer.asString(value);
                    }
                else if (validator.validate("__fjs_root_3#/items/0/anyOf/1", value))
                    json += serializer.asNumber(value);
                else
                    throw new TypeError(
                        `The value of '#/items/0' does not match schema definition.`
                    );

                if (0 < arrayEnd) {
                    json += JSON_STR_COMMA;
                }
            } else {
                throw new Error(`Item at 0 does not match schema definition.`);
            }
        }
        value = obj[1];
        if (1 < arrayLength) {
            if (undefined) {
                if (validator.validate("__fjs_root_3#/items/1/anyOf/0", value))
                    if (typeof value !== "string") {
                        if (value === null) {
                            json += JSON_STR_EMPTY_STRING;
                        } else if (value instanceof Date) {
                            json += JSON_STR_QUOTE + value.toISOString() + JSON_STR_QUOTE;
                        } else if (value instanceof RegExp) {
                            json += serializer.asString(value.source);
                        } else {
                            json += serializer.asString(value.toString());
                        }
                    } else {
                        json += serializer.asString(value);
                    }
                else if (validator.validate("__fjs_root_3#/items/1/anyOf/1", value))
                    json += serializer.asNumber(value);
                else
                    throw new TypeError(
                        `The value of '#/items/1' does not match schema definition.`
                    );

                if (1 < arrayEnd) {
                    json += JSON_STR_COMMA;
                }
            } else {
                throw new Error(`Item at 1 does not match schema definition.`);
            }
        }

        return JSON_STR_BEGIN_ARRAY + json + JSON_STR_END_ARRAY;
    }
    const main = anonymous0;
    return main;
});

The schema serializer above can be reproduced by either:

  1. setting break point at node_modules/fastify/lib/reply.js
function serialize (context, data, statusCode, contentType) {
  const fnSerialize = getSchemaSerializer(context, statusCode, contentType)
  if (fnSerialize) {
    return fnSerialize(data) // ===== step into this function ======
  }
  return JSON.stringify(data)
}
  1. generate the schema with Typebox and serialize with fast-json-stringify
const FastJson = require('fast-json-stringify');
const { Type } = require('@sinclair/typebox');
const { TypeCompiler } = require('@sinclair/typebox/compiler');

const StringOrNum = Type.Union([Type.Number(), Type.String()]);
const UnionTuple = Type.Tuple([StringOrNum, StringOrNum]);

const schema = TypeCompiler.Compile(UnionTuple ).schema;
console.log(JSON.stringify(schema));

const serializer = FastJson(schema);
console.log(serializer([1, 2])); // ===== step into this function ======

colin240215 avatar May 08 '24 01:05 colin240215

why does fast-json-stringify fails at generating the correct code? What's the input JSON Schema that is causing this?

mcollina avatar May 10 '24 17:05 mcollina

Tuple<Union> generates the schema below

{
  type: 'array',
  items: [
    { anyOf: [{ type: 'number' }, { type: 'string' }] },
    { anyOf: [{ type: 'number' }, { type: 'string' }] },
  ],
  additionalItems: false,
  minItems: 2,
  maxItems: 2,
}

Array<Union> generates this which works fine

{
  type: 'array',
  items: {
    anyOf: [{ type: 'number' }, { type: 'string' }],
  },
}

colin240215 avatar May 14 '24 01:05 colin240215

A PR with a fix for this would be really be nice. I guess items with multiple anyOf in it doesn't work.

mcollina avatar May 16 '24 12:05 mcollina