Skip to content

Valibot: correctly support format: int64 #2194

@Daschi1

Description

@Daschi1

Description:

A problem arises when an OpenAPI definition has format: int64, as is the case for the Repository object in the /user/repositories endpoint. One of the relevant variables is:

"size": {
  "type": "integer",
  "format": "int64",
  "x-go-name": "Size"
}

The Valibot plugin currently generates:

size: v.optional(v.bigint()),

However, this will throw the following error:

{
    kind: 'schema',
    type: 'bigint',
    input: 328,
    expected: 'bigint',
    received: '328',
    message: 'Invalid type: Expected bigint but received 328',
    requirement: undefined,
    path: [Array],
    issues: undefined,
    lang: undefined,
    abortEarly: undefined,
    abortPipeEarly: undefined
},

The API actually returns (only relevant part):

// ...
    "mirror": false,
    "size": 328,
    "language": "Swift",
// ...

I think the problem comes from the fact that Valibot expects an actual BigInt value (for example 328n), which is not possible since JSON disallows that suffix on numeric literals (hence the n will never be there). See https://valibot.dev/api/bigint/

Background

Looking at the OpenAPI spec section for data types (https://spec.openapis.org/oas/latest.html#data-types), it states that format: int64 is considered to be a

signed 64 bits (a.k.a long)

The linked Format Registry (https://spec.openapis.org/registry/format/) permits int64 values to be either JSON numbers or strings. Further, the OpenAPI spec says that type: integer is a valid substitution for type: number, essentially giving three data types (number, integer, and string) for which the int64 format specifier can apply.

Note: the spec itself only allows number (and, by consequence, integer) as a type. Only in the registry is string defined as an extension. The registry also specifies:

The existence of a format in this registry DOES NOT require tools to implement it.

Technically, to be fully OpenAPI spec compliant, support for quoted strings is optional. However, in my opinion it is necessary to implement this extension, since as a code generator for OpenAPI I would expect it to work with a variety of APIs, some of which might follow this extended format registry. The tradeoff to implement this in this context is minimal, as the example below will show.

Proposed solution

A Valibot pipeline should be generated that handles all scenarios allowed by the OpenAPI spec and registry. My proposal is:

// Define signed 64-bit range constants
const INT64_MIN = BigInt('-9223372036854775808');
const INT64_MAX = BigInt('9223372036854775807');

const Int64 = v.pipe(
  // 1) accept a JSON number, a string, or a BigInt literal
  v.union([ v.number(), v.string(), v.bigint() ]),
  // 2) convert into a JS bigint
  v.transform((x) => BigInt(x)),
  // 3) enforce the signed 64-bit range
  v.minValue(INT64_MIN, 'Invalid value: Expected int64 to be >= -2^63'),
  v.maxValue(INT64_MAX, 'Invalid value: Expected int64 to be <= 2^63-1')
);

Writing a quick Vitest to validate the pipeline looks like this:

import * as v from 'valibot';
import {describe, expect, it} from 'vitest';

// Define the Int64 (signed long) constants
const INT64_MIN = BigInt('-9223372036854775808');
const INT64_MAX = BigInt('9223372036854775807');

const Int64 = v.pipe(
    // 1) accept either a JSON number, a string, or a bigint
    // bigint is needed to also validate JSON that has values with the 'n' BigInt literal
    v.union([v.number(), v.string(), v.bigint()]),
    // 2) convert into a JS bigint
    v.transform((x) => BigInt(x)),
    // 3) enforce the signed 64-bit range
    v.minValue(INT64_MIN, 'Invalid value: Expected int64 to be >= -2^63'),
    v.maxValue(INT64_MAX, 'Invalid value: Expected int64 to be <= 2^63-1')
);

// Create a test schema with an Int64 field
const TestSchema = v.object({
  test: Int64
});

// Tests for the Int64 Validator
describe('Int64 Validator', () => {
  describe('Valid values', () => {
    describe('Within range', () => {
      it('should validate a regular number', () => {
        const input = {test: 123456789};
        const result = v.parse(TestSchema, input);
        expect(result.test).toBe(BigInt('123456789'));
      });

      it('should validate a string number', () => {
        const input = {test: "987654321"};
        const result = v.parse(TestSchema, input);
        expect(result.test).toBe(BigInt('987654321'));
      });

      it('should validate a BigInt with n literal', () => {
        const input = {test: 123456789n};
        const result = v.parse(TestSchema, input);
        expect(result.test).toBe(BigInt('123456789'));
      });
    });

    describe('Boundary values', () => {
      describe('Maximum values', () => {
        it('should validate the maximum valid value as string', () => {
          const input = {test: "9223372036854775807"};
          const result = v.parse(TestSchema, input);
          expect(result.test).toBe(BigInt('9223372036854775807'));
        });

        it('should validate a large number (close to maximum)', () => {
          // Note: JavaScript numbers can't accurately represent values this large,
          // but we'll use a slightly smaller number that can be accurately represented
          const input = {test: 9007199254740991}; // Number.MAX_SAFE_INTEGER
          const result = v.parse(TestSchema, input);
          expect(result.test).toBe(BigInt('9007199254740991'));
        });

        it('should validate the maximum valid value as BigInt with n literal', () => {
          const input = {test: 9223372036854775807n};
          const result = v.parse(TestSchema, input);
          expect(result.test).toBe(BigInt('9223372036854775807'));
        });
      });

      describe('Minimum values', () => {
        it('should validate the minimum valid value as string', () => {
          const input = {test: "-9223372036854775808"};
          const result = v.parse(TestSchema, input);
          expect(result.test).toBe(BigInt('-9223372036854775808'));
        });

        it('should validate a small number (close to minimum)', () => {
          // Note: JavaScript numbers can't accurately represent values this large,
          // but we'll use a slightly larger number that can be accurately represented
          const input = {test: -9007199254740991}; // -Number.MAX_SAFE_INTEGER
          const result = v.parse(TestSchema, input);
          expect(result.test).toBe(BigInt('-9007199254740991'));
        });

        it('should validate the minimum valid value as BigInt with n literal', () => {
          const input = {test: -9223372036854775808n};
          const result = v.parse(TestSchema, input);
          expect(result.test).toBe(BigInt('-9223372036854775808'));
        });
      });
    });
  });

  describe('Invalid values', () => {
    describe('Above maximum', () => {
      it('should reject a string value exceeding maximum', () => {
        const input = {test: "9223372036854775808"};
        expect(() => v.parse(TestSchema, input)).toThrowError(/Invalid value: Expected int64 to be <= 2\^63-1/);
      });


      it('should reject a BigInt with n literal exceeding maximum', () => {
        const input = {test: 9223372036854775808n};
        expect(() => v.parse(TestSchema, input)).toThrowError(/Invalid value: Expected int64 to be <= 2\^63-1/);
      });
    });

    describe('Below minimum', () => {
      it('should reject a string value below minimum', () => {
        const input = {test: "-9223372036854775809"};
        expect(() => v.parse(TestSchema, input)).toThrowError(/Invalid value: Expected int64 to be >= -2\^63/);
      });


      it('should reject a BigInt with n literal below minimum', () => {
        const input = {test: -9223372036854775809n};
        expect(() => v.parse(TestSchema, input)).toThrowError(/Invalid value: Expected int64 to be >= -2\^63/);
      });
    });

    describe('Non-parsable values', () => {
      it('should reject a non-numeric string', () => {
        const input = {test: "not-a-number"};
        expect(() => v.parse(TestSchema, input)).toThrowError(/Cannot convert/);
      });
    });
  });
});

Running this confirms that the Valibot pipeline validates the input according to the OpenAPI specification and our earlier identified requirements for the int64 format.

Reproducible example or configuration

Client generation:

import { createClient, UserConfig } from '@hey-api/openapi-ts';

const userConfig: UserConfig = {
  input: 'https://code.forgejo.org/swagger.v1.json',
  output: 'src/client',
  plugins: [
    { name: '@hey-api/client-fetch', bundle: true, baseUrl: 'https://code.forgejo.org' },
    { name: '@hey-api/typescript', enums: 'javascript' },
    { name: '@hey-api/sdk', validator: true, transformer: true },
    { name: 'valibot' },
    { name: '@hey-api/transformers', dates: true }
  ],
  logs: { file: false, level: 'debug' },
  dryRun: false
};

createClient(userConfig)
  .then(() => console.log('Client generation completed successfully'))
  .catch((error) => console.error('Failed to generate client:', error));

Runtime test:

import { userCurrentListRepos } from './dist/client';

async function testForgejoClient() {
  console.log('Starting Forgejo API test...');
  const token = process.env.FORGEJO_TOKEN;
  const response = await userCurrentListRepos({
    headers: { Authorization: `token ${token}` },
    query: { limit: 1 }
  });
  console.log(`Found ${response.data?.length} repositories. Size of first repo: ${response.data?.[0].size}`);
}

testForgejoClient()
  .then(() => console.log('Test completed successfully!'))
  .catch((error) => {
    console.error('Test failed:', error.message);
    process.exit(1);
  });

OpenAPI specification (optional)

https://code.forgejo.org/swagger.v1.json


System information (optional)

  • @hey-api/openapi-ts version 0.73.0
  • valibot version 1.1.0
  • OS: Windows 11
  • Node.js: v22.16.0
  • TypeScript: 5.8.3

Metadata

Metadata

Assignees

No one assigned

    Labels

    bug 🔥Something isn't workingfeature 🚀New feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions