diff --git a/.eslintrc b/.eslintrc deleted file mode 100644 index 8be354d..0000000 --- a/.eslintrc +++ /dev/null @@ -1,67 +0,0 @@ -{ - "parserOptions": { - "ecmaVersion": 9 - }, - "rules": { - "arrow-body-style": "off", - "camelcase": "off", - "class-methods-use-this": "off", - "comma-dangle": [ - "error", - { - "arrays": "always-multiline", - "objects": "always-multiline", - "imports": "always-multiline", - "exports": "always-multiline", - "functions": "never" - } - ], - "default-case": "off", - "consistent-return": "off", - "func-names": "off", - "import/extensions": "off", - "import/first": "off", - "import/newline-after-import": "off", - "import/no-named-as-default": "off", - "import/no-named-as-default-member": "off", - "import/prefer-default-export": "off", - "indent": ["error", 2], - "max-len": "off", - "no-mixed-operators": "off", - "no-param-reassign": "off", - "no-plusplus": "off", - "no-prototype-builtins": "off", - "no-restricted-syntax": "off", - "no-underscore-dangle": "off", - "no-useless-escape": "off", - "no-use-before-define": [ - "error", - { - "functions": false, - "classes": true, - "variables": true - } - ], - "no-var": "off", - "object-property-newline": "off", - "operator-assignment": "off", - "prefer-arrow-callback": "off", - "prefer-rest-params": "off", - "prefer-spread": "off", - "prefer-template": "off", - "object-curly-newline": "off", - "prefer-destructuring": "off", - "no-restricted-globals": "off", - "radix": "off", - "space-before-function-paren": [ - "error", - { - "anonymous": "never", - "named": "never" - } - ], - "linebreak-style": "off", - "no-lonely-if": "off" - } -} - \ No newline at end of file diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml new file mode 100644 index 0000000..381c93d --- /dev/null +++ b/.github/workflows/pr-validation.yml @@ -0,0 +1,31 @@ +name: pr-validation + +on: + pull_request: + branches: + - '*' + +jobs: + build: + + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [22.x, 24.x] + + steps: + - uses: actions/checkout@v1 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + - name: npm install, build, and test + run: | + npm i + npm run build + npm run lint + npm run test + npm run bench + env: + CI: true \ No newline at end of file diff --git a/.gitignore b/.gitignore index b6cf311..2500e3a 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,10 @@ api_docs /benchmarks/compare.js yarn.lock package-lock.json -.clinic \ No newline at end of file +.clinic + +# Build output +dist/ + +# TypeScript +*.tsbuildinfo \ No newline at end of file diff --git a/.npmignore b/.npmignore deleted file mode 100644 index 9747b89..0000000 --- a/.npmignore +++ /dev/null @@ -1,9 +0,0 @@ -tests -README.md -.travis.yml -benchmarks -yarn.lock -package-lock.json -docs -.eslintrc -.clinic \ No newline at end of file diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 91edb73..0000000 --- a/.travis.yml +++ /dev/null @@ -1,6 +0,0 @@ -language: node_js -node_js: - - "10" - - "12" -script: "npm run test && npm run bench" -cache: yarn diff --git a/README.md b/README.md index 8f5a257..9d910ce 100644 --- a/README.md +++ b/README.md @@ -1,146 +1,157 @@

- + Compactr

Compactr

- Schema based serialization made easy + OpenAPI serialization



-[![Compactr](https://img.shields.io/npm/v/compactr.svg)](https://www.npmjs.com/package/compactr) -[![Build Status](https://travis-ci.org/compactr/compactr.js.svg?branch=master)](https://travis-ci.org/compactr/compactr.js) -[![Gitter](https://img.shields.io/gitter/room/compactr/compactr.svg)](https://gitter.im/compactr/compactr) - ---- - -## What is this and why does it matter? - -Protocol Buffers are awesome. Having schemas to deflate and inflate data while maintaining some kind of validation is a great concept. Compactr's goal is to build on that to better suit the Javascript ecosystem. - -[More](docs/ABOUT.md) - +- **OpenAPI 3.x Compatible** Use your existing OpenAPI schemas, no extra definitions required +- **Cuts bandwidth in half** Average API responses are multiple times smaller than JSON +- **Built for actual APIs** Specialty field types to optimize common items, ex: UUID, date-time, ivp4 +- **Zero dependencies** and can be bundled down to ~5kb! ## Install +```bash +npm install compactr ``` - npm install compactr -``` - -## Implementation +## Usage -```node +### Basic Example -const Compactr = require('compactr'); +```typescript +import { schema } from 'compactr'; -// Defining a schema -const userSchema = Compactr.schema({ - id: { type: 'number' }, - name: { type: 'string' } +const userSchema = schema({ + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + name: { type: 'string' }, + age: { type: 'integer', format: 'int32' }, + balance: { type: 'number', format: 'double' }, + created: { type: 'string', format: 'date-time' }, + tags: { type: 'array', items: { type: 'string' } } + } }); +const data = { + id: '550e8400-e29b-4d4e-a7d4-426614174000', + name: 'Alice', + age: 30, + balance: 1234.56, + created: '2025-10-28T14:30:00.000Z', + tags: ['premium', 'verified'] +}; + +const buffer = userSchema.write(data); +const decoded = userSchema.read(buffer); +``` +Compactr also supports component references ($ref). + +*Only local references are allowed* + +**Component References** + +```typescript +const schema = schema( + { + user: { $ref: '#/User' }, + product: { $ref: '#/Product' } + }, + { + schemas: { + User: { + type: 'object', + properties: { + id: { type: 'integer', format: 'int32' }, + name: { type: 'string' } + } + }, + Product: { + type: 'object', + properties: { + sku: { type: 'string' }, + price: { type: 'number', format: 'double' } + } + } + } + } +); +``` -// Encoding -userSchema.write({ id: 123, name: 'John' }); - -// Get the header bytes -const header = userSchema.headerBuffer(); - -// Get the content bytes -const partial = userSchema.contentBuffer(); +## Supported Types -// Get the full payload (header + content bytes) -const buffer = userSchema.buffer(); +Compactr supports the following OpenAPI types and formats: +| Type | Format | Bytes | Description | +| --- | --- | --- | --- | +| boolean | - | 1 | Boolean value | +| integer | int32 | 4 | 32-bit integer | +| integer | int64 | 8 | 64-bit integer | +| number | float | 4 | 32-bit floating point | +| number | double | 8 | 64-bit floating point | +| string | - | 2/char | UTF-8 string | +| string | uuid | 16 | UUID (compressed) | +| string | ipv4 | 4 | IPv4 address | +| string | ipv6 | 16 | IPv6 address | +| string | date | 4 | Date (YYYY-MM-DD) | +| string | date-time | 8 | ISO 8601 date-time | +| string | binary | variable | Base64 binary data | +| array | - | variable | Array of items | +| object | - | variable | Nested object | +## Performance -// Decoding a full payload -const content = userSchema.read(buffer); +I realistic scenarios, compactr performs a bit slower than JSON.stringify/ JSON.parse as well as `protobuf`, but can yield a byte reduction of 3.5x compared to JSON. -// Decoding a partial payload (content) -const content = userSchema.readContent(partial); ``` +[JSON-API Reponse] JSON x 379 ops/sec ±0.66% (92 runs sampled) +[JSON-API Reponse] Compactr x 167 ops/sec ±0.75% (85 runs sampled) +[JSON-API Reponse] Protobuf x 358 ops/sec ±1.36% (91 runs sampled) +[JSON-API Reponse] MsgPack x 161 ops/sec ±2.06% (79 runs sampled) -## Size comparison - -JSON: `{"id":123,"name":"John"}`: 24 bytes - -Compactr (full): ``: 10 bytes - -Compactr (partial): ``: 5 bytes - +Buffer size (bytes): { json: 277, compactr: 80, protobuf: 129, msgpack: 227 } +``` -## Protocol details +## Testing -### Data types +Run the test suite: -Type | Count bytes | Byte size ---- | --- | --- -boolean | 0 | 1 -number | 0 | 8 -int8 | 0 | 1 -int16 | 0 | 2 -int32 | 0 | 4 -double | 0 | 8 -string | 1 | 2/char -char8 | 1 | 1/char -char16 | 1 | 2/char -char32 | 1 | 4/char -array | 1 | (x)/entry -object | 1 | (x) -unsigned | 0 | 8 -unsigned8 | 0 | 1 -unsigned16 | 0 | 2 -unsigned32 | 0 | 4 +```bash +npm test +``` -* Count bytes range can be specified per-item in the schema* +Run benchmarks: -See the full [Compactr protocol](https://github.com/compactr/protocol) +```bash +npm run bench +``` -## Benchmarks +Build the project: -``` -[Array] JSON x 737 ops/sec ±1.04% (91 runs sampled) -[Array] Compactr x 536 ops/sec ±1.25% (82 runs sampled) -[Array] size: { json: 0, compactr: 10 } - -[Boolean] JSON x 996 ops/sec ±0.84% (93 runs sampled) -[Boolean] Compactr x 1,439 ops/sec ±1.24% (83 runs sampled) -[Boolean] Protobuf x 2,585 ops/sec ±1.32% (91 runs sampled) -[Boolean] size: { json: 23, compactr: 5, protobuf: 5 } - -[Float] JSON x 713 ops/sec ±1.29% (88 runs sampled) -[Float] Compactr x 1,056 ops/sec ±1.38% (86 runs sampled) -[Float] Protobuf x 2,432 ops/sec ±1.45% (88 runs sampled) -[Float] size: { json: 41, compactr: 12, protobuf: 12 } - -[Integer] JSON x 954 ops/sec ±0.77% (93 runs sampled) -[Integer] Compactr x 1,520 ops/sec ±1.21% (89 runs sampled) -[Integer] Protobuf x 2,676 ops/sec ±1.22% (90 runs sampled) -[Integer] size: { json: 24, compactr: 8, protobuf: 7 } - -[Object] JSON x 512 ops/sec ±0.62% (90 runs sampled) -[Object] Compactr x 412 ops/sec ±1.46% (81 runs sampled) -[Object] Protobuf x 898 ops/sec ±0.92% (93 runs sampled) -[Object] size: { json: 46, compactr: 13, protobuf: 25 } - -[String] JSON x 432 ops/sec ±0.79% (88 runs sampled) -[String] Compactr x 428 ops/sec ±0.87% (87 runs sampled) -[String] Protobuf x 738 ops/sec ±0.71% (90 runs sampled) -[String] size: { json: 57, compactr: 14, protobuf: 28 } +```bash +npm run build ``` -## Want to help ? +## Contributing -You are awesome! Open an issue on this project, identifying the feature that you want to tackle and we'll take the discussion there! +Contributions are welcome! Please feel free to submit a Pull Request. +1. Fork the repository +2. Create your feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'Add some amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request -## License +## License -[Apache 2.0](LICENSE) (c) 2020 Frederic Charette +[Apache 2.0](LICENSE) (c) 2025 Frederic Charette diff --git a/benchmarks/array.js b/benchmarks/array.js deleted file mode 100644 index c1e15c7..0000000 --- a/benchmarks/array.js +++ /dev/null @@ -1,47 +0,0 @@ -/** Benchmarks */ - -/* Requires ------------------------------------------------------------------*/ - -const Benchmark = require('benchmark'); -const Compactr = require('../'); - -/* Local variables -----------------------------------------------------------*/ - - -let User = Compactr.schema({ - id: { type: 'int32', size: 4 }, - arr: { type: 'array', size: 6, items: { type: 'char8', size: 1 }}, -}); - -const mult = 32; -const sizes = { json: 0, compactr: 0 }; - -const arraySuite = new Benchmark.Suite(); - -/* Float suite ---------------------------------------------------------------*/ - -arraySuite.add('[Array] JSON', arrJSON) - .add('[Array] Compactr', arrCompactr) - .on('cycle', e => console.log(String(e.target))) - .run({ 'async': true }) - .on('complete', _ => console.log(sizes)); - -function arrJSON() { - let packed, unpacked; - - for(let i = 0; i sizes.json) sizes.json = packed.length; - } -} - -function arrCompactr() { - let packed, unpacked; - - for(let i = 0; i sizes.compactr) sizes.compactr = packed.length; - } -} \ No newline at end of file diff --git a/benchmarks/array.ts b/benchmarks/array.ts new file mode 100644 index 0000000..bb379ff --- /dev/null +++ b/benchmarks/array.ts @@ -0,0 +1,91 @@ +/** Benchmarks */ + +/* Requires ------------------------------------------------------------------*/ + +import Benchmark from 'benchmark'; +import {schema} from '../dist/compactr.js'; +import protobuf from 'protobufjs'; +import * as msgpack from '@msgpack/msgpack'; +import {deferred} from './utils.ts'; + +/* Local variables -----------------------------------------------------------*/ + + +let User = schema({ + id: { type: 'integer', format: 'int32' }, + arr: { type: 'array', items: { type: 'string' }}, +}); + +let root = protobuf.Root.fromJSON({ + nested: { + ArrayBenchTest: { + fields: { + id: { type: 'uint32', id: 1 }, + arr: { rule: 'repeated', type: 'string', id: 2 }, + }, + }, + }, +}); +var ArrayBenchTest = root.lookupType('ArrayBenchTest'); + +const sizes = { json: 0, compactr: 0, protobuf: 0, msgpack: 0 }; + +const arraySuite = new Benchmark.Suite(); + +/* Array suite ---------------------------------------------------------------*/ + +export function init(mult) { + const {promise, resolve} = deferred(); + + arraySuite.add('[Array] JSON', arrJSON) + .add('[Array] Compactr', arrCompactr) + .add('[Array] Protobuf', arrProtobuf) + .add('[Array] MsgPack', arrMsgPack) + .on('cycle', e => console.log(String(e.target))) + .run({ 'async': true }) + .on('complete', _ => resolve(sizes)); + + + function arrJSON() { + let packed, unpacked; + + for(let i = 0; i sizes.json) sizes.json = packed.length; + } + } + + function arrCompactr() { + let packed, unpacked; + + for(let i = 0; i sizes.compactr) sizes.compactr = packed.length; + } + } + + function arrProtobuf() { + let packed, unpacked; + + for(let i = 0; i sizes.protobuf) sizes.protobuf = packed.length; + } + } + + function arrMsgPack() { + let packed, unpacked; + + for(let i = 0; i sizes.msgpack) sizes.msgpack = packed.length; + } + } + + return promise; +} diff --git a/benchmarks/boolean.js b/benchmarks/boolean.js deleted file mode 100644 index 405efd6..0000000 --- a/benchmarks/boolean.js +++ /dev/null @@ -1,72 +0,0 @@ -/** Benchmarks */ - -/* Requires ------------------------------------------------------------------*/ - -const Benchmark = require('benchmark'); -const Compactr = require('../'); -const protobuf = require('protobufjs'); - -/* Local variables -----------------------------------------------------------*/ - - -let User = Compactr.schema({ - id: { type: 'int32', size: 4 }, - bool: { type: 'boolean' }, -}); - -const mult = 32; -const sizes = { json: 0, compactr: 0, protobuf: 0 }; - -let root = protobuf.Root.fromJSON({ - nested: { - BoolBenchTest: { - fields: { - id: { type: 'uint32', id: 1 }, - bool: { type: 'bool', id: 2 }, - }, - }, - }, -}); -var BoolBenchTest = root.lookupType('BoolBenchTest'); - -const boolSuite = new Benchmark.Suite(); - -/* Float suite ---------------------------------------------------------------*/ - -boolSuite.add('[Boolean] JSON', boolJSON) - .add('[Boolean] Compactr', boolCompactr) - .add('[Boolean] Protobuf', boolProtobuf) - .on('cycle', e => console.log(String(e.target))) - .run({ 'async': true }) - .on('complete', _ => console.log(sizes)); - -function boolJSON(e) { - let packed, unpacked; - - for(let i = 0; i sizes.json) sizes.json = packed.length; - } -} - -function boolCompactr() { - let packed, unpacked; - - for(let i = 0; i sizes.compactr) sizes.compactr = packed.length; - } -} - -function boolProtobuf() { - let packed, unpacked; - - for(let i = 0; i sizes.protobuf) sizes.protobuf = packed.length; - } -} \ No newline at end of file diff --git a/benchmarks/boolean.ts b/benchmarks/boolean.ts new file mode 100644 index 0000000..9ee7420 --- /dev/null +++ b/benchmarks/boolean.ts @@ -0,0 +1,90 @@ +/** Benchmarks */ + +/* Requires ------------------------------------------------------------------*/ + +import Benchmark from 'benchmark'; +import {schema} from '../dist/compactr.js'; +import protobuf from 'protobufjs'; +import * as msgpack from '@msgpack/msgpack'; +import {deferred} from './utils.ts'; + +/* Local variables -----------------------------------------------------------*/ + + +let User = schema({ + id: { type: 'integer', format: 'int32' }, + bool: { type: 'boolean' }, +}); + +const sizes = { json: 0, compactr: 0, protobuf: 0, msgpack: 0 }; + +let root = protobuf.Root.fromJSON({ + nested: { + BoolBenchTest: { + fields: { + id: { type: 'uint32', id: 1 }, + bool: { type: 'bool', id: 2 }, + }, + }, + }, +}); +var BoolBenchTest = root.lookupType('BoolBenchTest'); + +const boolSuite = new Benchmark.Suite(); + +/* Boolean suite ---------------------------------------------------------------*/ + +export function init(mult) { + const {promise, resolve} = deferred(); + + boolSuite.add('[Boolean] JSON', boolJSON) + .add('[Boolean] Compactr', boolCompactr) + .add('[Boolean] Protobuf', boolProtobuf) + .add('[Boolean] MsgPack', boolMsgPack) + .on('cycle', e => console.log(String(e.target))) + .run({ 'async': true }) + .on('complete', _ => resolve(sizes)); + + function boolJSON(e) { + let packed, unpacked; + + for(let i = 0; i sizes.json) sizes.json = packed.length; + } + } + + function boolCompactr() { + let packed, unpacked; + + for(let i = 0; i sizes.compactr) sizes.compactr = packed.length; + } + } + + function boolProtobuf() { + let packed, unpacked; + + for(let i = 0; i sizes.protobuf) sizes.protobuf = packed.length; + } + } + + function boolMsgPack() { + let packed, unpacked; + + for(let i = 0; i sizes.msgpack) sizes.msgpack = packed.length; + } + } + + return promise; +} diff --git a/benchmarks/double.js b/benchmarks/double.js deleted file mode 100644 index b7bc1ce..0000000 --- a/benchmarks/double.js +++ /dev/null @@ -1,72 +0,0 @@ -/** Benchmarks */ - -/* Requires ------------------------------------------------------------------*/ - -const Benchmark = require('benchmark'); -const Compactr = require('../'); -const protobuf = require('protobufjs'); - -/* Local variables -----------------------------------------------------------*/ - - -let User = Compactr.schema({ - id: { type: 'int32', size: 4 }, - int: { type: 'double', size: 8 }, -}); - -const mult = 32; -const sizes = { json: 0, compactr: 0, protobuf: 0 }; - -let root = protobuf.Root.fromJSON({ - nested: { - DoubleBenchTest: { - fields: { - id: { type: 'uint32', id: 1 }, - int: { type: 'double', id: 2 }, - }, - }, - }, -}); -var DoubleBenchTest = root.lookupType('DoubleBenchTest'); - -const floatSuite = new Benchmark.Suite(); - -/* Float suite ---------------------------------------------------------------*/ - -floatSuite.add('[Float] JSON', floatJSON) - .add('[Float] Compactr', floatCompactr) - .add('[Float] Protobuf', floatProtobuf) - .on('cycle', e => console.log(String(e.target))) - .run({ 'async': true }) - .on('complete', _ => console.log(sizes)); - -function floatJSON() { - let packed, unpacked; - - for(let i = 0; i sizes.json) sizes.json = packed.length; - } -} - -function floatCompactr() { - let packed, unpacked; - - for(let i = 0; i sizes.compactr) sizes.compactr = packed.length; - } -} - -function floatProtobuf() { - let packed, unpacked; - - for(let i = 0; i sizes.protobuf) sizes.protobuf = packed.length; - } -} \ No newline at end of file diff --git a/benchmarks/index.ts b/benchmarks/index.ts new file mode 100644 index 0000000..8e71dcd --- /dev/null +++ b/benchmarks/index.ts @@ -0,0 +1,30 @@ +// Synthetic +import { init as array } from './array.ts'; +import { init as boolean } from './boolean.ts'; +import { init as integer } from './integer.ts'; +import { init as schema } from './schema.ts'; +import { init as string } from './string.ts'; +import { init as uuid } from './uuid.ts'; + +// Realistic +import { init as jsonapiresponse } from './jsonapiresponse.ts'; + +import { sequence } from './utils.ts'; + +const benchmarks = [ + /*array, + boolean, + integer, + schema, + string, + uuid,*/ + jsonapiresponse, +]; + +const mult = 32; + +console.log('Running Compactr benchmarks...\n'); + +sequence(benchmarks, (i) => i(mult).then((sizes) => console.log(sizes))).then(() => { + console.log('\nAll benchmarks completed!'); +}); diff --git a/benchmarks/integer.js b/benchmarks/integer.js deleted file mode 100644 index beeee55..0000000 --- a/benchmarks/integer.js +++ /dev/null @@ -1,72 +0,0 @@ -/** Benchmarks */ - -/* Requires ------------------------------------------------------------------*/ - -const Benchmark = require('benchmark'); -const Compactr = require('../'); -const protobuf = require('protobufjs'); - -/* Local variables -----------------------------------------------------------*/ - - -let User = Compactr.schema({ - id: { type: 'int32', size: 4 }, - int: { type: 'int32', size: 8 }, -}); - -const mult = 32; -const sizes = { json: 0, compactr: 0, protobuf: 0 }; - -let root = protobuf.Root.fromJSON({ - nested: { - IntBenchTest: { - fields: { - id: { type: 'uint32', id: 1 }, - int: { type: 'int32', id: 2 }, - }, - }, - }, -}); -var IntBenchTest = root.lookupType('IntBenchTest'); - -const intSuite = new Benchmark.Suite(); - -/* Float suite ---------------------------------------------------------------*/ - -intSuite.add('[Integer] JSON', intJSON) - .add('[Integer] Compactr', intCompactr) - .add('[Integer] Protobuf', intProtobuf) - .on('cycle', e => console.log(String(e.target))) - .run({ 'async': true }) - .on('complete', _ => console.log(sizes)); - -function intJSON() { - let packed, unpacked; - - for(let i = 0; i sizes.json) sizes.json = packed.length; - } -} - -function intCompactr() { - let packed, unpacked; - - for(let i = 0; i sizes.compactr) sizes.compactr = packed.length; - } -} - -function intProtobuf() { - let packed, unpacked; - - for(let i = 0; i sizes.protobuf) sizes.protobuf = packed.length; - } -} \ No newline at end of file diff --git a/benchmarks/integer.ts b/benchmarks/integer.ts new file mode 100644 index 0000000..19d6d88 --- /dev/null +++ b/benchmarks/integer.ts @@ -0,0 +1,89 @@ +/** Benchmarks */ + +/* Requires ------------------------------------------------------------------*/ + +import Benchmark from 'benchmark'; +import {schema} from '../dist/compactr.js'; +import protobuf from 'protobufjs'; +import * as msgpack from '@msgpack/msgpack'; +import {deferred} from './utils.ts'; + +/* Local variables -----------------------------------------------------------*/ + +let User = schema({ + id: { type: 'integer', format: 'int32' }, + int: { type: 'integer', format: 'int32' }, +}); + +const sizes = { json: 0, compactr: 0, protobuf: 0, msgpack: 0 }; + +let root = protobuf.Root.fromJSON({ + nested: { + IntBenchTest: { + fields: { + id: { type: 'uint32', id: 1 }, + int: { type: 'int32', id: 2 }, + }, + }, + }, +}); +var IntBenchTest = root.lookupType('IntBenchTest'); + +const intSuite = new Benchmark.Suite(); + +/* Integer suite ---------------------------------------------------------------*/ + +export function init(mult) { + const {promise, resolve} = deferred(); + + intSuite.add('[Integer] JSON', intJSON) + .add('[Integer] Compactr', intCompactr) + .add('[Integer] Protobuf', intProtobuf) + .add('[Integer] MsgPack', intMsgPack) + .on('cycle', e => console.log(String(e.target))) + .run({ 'async': true }) + .on('complete', _ => resolve(sizes)); + + function intJSON() { + let packed, unpacked; + + for(let i = 0; i sizes.json) sizes.json = packed.length; + } + } + + function intCompactr() { + let packed, unpacked; + + for(let i = 0; i sizes.compactr) sizes.compactr = packed.length; + } + } + + function intProtobuf() { + let packed, unpacked; + + for(let i = 0; i sizes.protobuf) sizes.protobuf = packed.length; + } + } + + function intMsgPack() { + let packed, unpacked; + + for(let i = 0; i sizes.msgpack) sizes.msgpack = packed.length; + } + } + + return promise; +} diff --git a/benchmarks/jsonapiresponse.ts b/benchmarks/jsonapiresponse.ts new file mode 100644 index 0000000..deceee1 --- /dev/null +++ b/benchmarks/jsonapiresponse.ts @@ -0,0 +1,180 @@ +/** Benchmarks */ + +/* Requires ------------------------------------------------------------------*/ + +import Benchmark from 'benchmark'; +import {schema} from '../dist/compactr.js'; +import protobuf from 'protobufjs'; +import * as msgpack from '@msgpack/msgpack'; +import {deferred} from './utils.ts'; +import {randomUUID} from 'crypto'; + +/* Local variables -----------------------------------------------------------*/ + +function generateIPDigit() { + return Math.floor(Math.random() * 255); +} + +let User = schema({ + id: { type: 'string', format: 'uuid' }, + name: { type: 'string' }, + age: { type: 'integer', format: 'int32' }, + last_connected_ip: { type: 'string', format: 'ipv4' }, + date_created: { type: 'string', format: 'date-time' }, + date_updated: { type: 'string', format: 'date-time' }, + user_settings: { + type: 'object', + properties: { + flag_a: { type: 'boolean' }, + flag_b: { type: 'boolean' }, + flag_c: { type: 'boolean' }, + } + }, + user_friends: { + type: 'array', + items: { type: 'string', format: 'uuid' } + } +}); + +const sizes = { json: 0, compactr: 0, protobuf: 0, msgpack: 0 }; + +let root = protobuf.Root.fromJSON({ + nested: { + SettingsBenchTest: { + fields: { + flag_a: { type: 'bool', id: 1 }, + flag_b: { type: 'bool', id: 2 }, + flag_c: { type: 'bool', id: 3 }, + }, + }, + JsonAPIBenchTest: { + fields: { + id: { type: 'string', id: 1 }, + name: { type: 'string', id: 2}, + age: { type: 'int32', id: 3 }, + last_connected_ip: { type: 'string', id: 4}, + date_created: { type: 'string', id: 5 }, + date_updated: { type: 'string', id: 6 }, + user_settings: { type: 'SettingsBenchTest', id: 7}, + user_friends: { rule: 'repeated', type: 'string', id: 8 } + }, + }, + }, +}); +var ObjectBenchTest = root.lookupType('JsonAPIBenchTest'); + +const objectSuite = new Benchmark.Suite(); + +/* JSON-API Reponse suite ---------------------------------------------------------------*/ + +export function init(mult) { + const {promise, resolve} = deferred(); + + objectSuite.add('[JSON-API Reponse] JSON', objJSON) + .add('[JSON-API Reponse] Compactr', objCompactr) + .add('[JSON-API Reponse] Protobuf', objProtobuf) + .add('[JSON-API Reponse] MsgPack', objMsgPack) + .on('cycle', e => console.log(String(e.target))) + .run({ 'async': true }) + .on('complete', _ => resolve(sizes)); + + + function objJSON() { + let packed, unpacked; + let now = (new Date()).toISOString(); + + for(let i = 0; i sizes.json) sizes.json = packed.length; + } + } + + function objCompactr() { + let packed, unpacked; + let now = (new Date()).toISOString(); + + for(let i = 0; i sizes.compactr) sizes.compactr = packed.length; + } + } + + function objProtobuf() { + let packed, unpacked; + let now = (new Date()).toISOString(); + + for(let i = 0; i sizes.protobuf) sizes.protobuf = packed.length; + } + } + + function objMsgPack() { + let packed, unpacked; + let now = (new Date()).toISOString(); + + for(let i = 0; i sizes.msgpack) sizes.msgpack = packed.length; + } + } + + return promise; +} diff --git a/benchmarks/object.js b/benchmarks/object.js deleted file mode 100644 index 92c07de..0000000 --- a/benchmarks/object.js +++ /dev/null @@ -1,78 +0,0 @@ -/** Benchmarks */ - -/* Requires ------------------------------------------------------------------*/ - -const Benchmark = require('benchmark'); -const Compactr = require('../'); -const protobuf = require('protobufjs'); - -/* Local variables -----------------------------------------------------------*/ - - -let User = Compactr.schema({ - id: { type: 'int32', size: 4 }, - obj: { type: 'object', size: 9, schema: { str: { type: 'string', size: 6 } } }, -}); - -const mult = 32; -const sizes = { json: 0, compactr: 0, protobuf: 0 }; - -let root = protobuf.Root.fromJSON({ - nested: { - StringBenchTest: { - fields: { - str: { type: 'string', id: 2 }, - }, - }, - ObjectBenchTest: { - fields: { - id: { type: 'uint32', id: 1 }, - obj: { type: 'StringBenchTest', id: 2}, - }, - }, - }, -}); -var ObjectBenchTest = root.lookupType('ObjectBenchTest'); - -const objectSuite = new Benchmark.Suite(); - -/* Float suite ---------------------------------------------------------------*/ - -objectSuite.add('[Object] JSON', objJSON) - .add('[Object] Compactr', objCompactr) - .add('[Object] Protobuf', objProtobuf) - .on('cycle', e => console.log(String(e.target))) - .run({ 'async': true }) - .on('complete', _ => console.log(sizes)); - - -function objJSON() { - let packed, unpacked; - - for(let i = 0; i sizes.json) sizes.json = packed.length; - } -} - -function objCompactr() { - let packed, unpacked; - - for(let i = 0; i sizes.compactr) sizes.compactr = packed.length; - } -} - -function objProtobuf() { - let packed, unpacked; - - for(let i = 0; i sizes.protobuf) sizes.protobuf = packed.length; - } -} \ No newline at end of file diff --git a/benchmarks/schema.ts b/benchmarks/schema.ts new file mode 100644 index 0000000..84e6ae5 --- /dev/null +++ b/benchmarks/schema.ts @@ -0,0 +1,102 @@ +/** Benchmarks */ + +/* Requires ------------------------------------------------------------------*/ + +import Benchmark from 'benchmark'; +import {schema} from '../dist/compactr.js'; +import protobuf from 'protobufjs'; +import * as msgpack from '@msgpack/msgpack'; +import {deferred} from './utils.ts'; +import {randomUUID} from 'crypto'; + +/* Local variables -----------------------------------------------------------*/ + + +let User = schema({ + id: { type: 'integer', format: 'int32' }, + obj: { + type: 'object', + properties: { + str: { type: 'string' } + }, + }, +}); + +const sizes = { json: 0, compactr: 0, protobuf: 0, msgpack: 0 }; + +let root = protobuf.Root.fromJSON({ + nested: { + StringBenchTest: { + fields: { + str: { type: 'string', id: 2 }, + }, + }, + ObjectBenchTest: { + fields: { + id: { type: 'uint32', id: 1 }, + obj: { type: 'StringBenchTest', id: 2}, + }, + }, + }, +}); +var ObjectBenchTest = root.lookupType('ObjectBenchTest'); + +const objectSuite = new Benchmark.Suite(); + +/* Schema suite ---------------------------------------------------------------*/ + +export function init(mult) { + const {promise, resolve} = deferred(); + + objectSuite.add('[Schema] JSON', objJSON) + .add('[Schema] Compactr', objCompactr) + .add('[Schema] Protobuf', objProtobuf) + .add('[Schema] MsgPack', objMsgPack) + .on('cycle', e => console.log(String(e.target))) + .run({ 'async': true }) + .on('complete', _ => resolve(sizes)); + + + function objJSON() { + let packed, unpacked; + + for(let i = 0; i sizes.json) sizes.json = packed.length; + } + } + + function objCompactr() { + let packed, unpacked; + + for(let i = 0; i sizes.compactr) sizes.compactr = packed.length; + } + } + + function objProtobuf() { + let packed, unpacked; + + for(let i = 0; i sizes.protobuf) sizes.protobuf = packed.length; + } + } + + function objMsgPack() { + let packed, unpacked; + + for(let i = 0; i sizes.msgpack) sizes.msgpack = packed.length; + } + } + + return promise; +} diff --git a/benchmarks/string.js b/benchmarks/string.js deleted file mode 100644 index 8650e66..0000000 --- a/benchmarks/string.js +++ /dev/null @@ -1,75 +0,0 @@ -/** Benchmarks */ - -/* Requires ------------------------------------------------------------------*/ - -const Benchmark = require('benchmark'); -const Compactr = require('../'); -const protobuf = require('protobufjs'); - -/* Local variables -----------------------------------------------------------*/ - - -let User = Compactr.schema({ - id: { type: 'int32', size: 4 }, - str: { type: 'char8', size: 6 }, - special: { type: 'char32', size: 4 }, -}); - -let root = protobuf.Root.fromJSON({ - nested: { - StringBenchTest: { - fields: { - id: { type: 'uint32', id: 1 }, - str: { type: 'string', id: 2 }, - special: { type: 'string', id: 3 }, - }, - }, - }, -}); -var StringBenchTest = root.lookupType('StringBenchTest'); - -const mult = 32; -const sizes = { json: 0, compactr: 0, protobuf: 0 }; - -const stringSuite = new Benchmark.Suite(); - -/* Float suite ---------------------------------------------------------------*/ - -stringSuite.add('[String] JSON', strJSON) - .add('[String] Compactr', strCompactr) - .add('[String] Protobuf', strProtobuf) - .on('cycle', e => console.log(String(e.target))) - .run({ 'async': true }) - .on('complete', _ => console.log(sizes)); - - -function strJSON() { - let packed, unpacked; - - for(let i = 0; i sizes.json) sizes.json = packed.length; - } -} - -function strCompactr() { - let packed, unpacked; - - for(let i = 0; i sizes.compactr) sizes.compactr = packed.length; - } -} - -function strProtobuf() { - let packed, unpacked; - - for(let i = 0; i sizes.protobuf) sizes.protobuf = packed.length; - } -} \ No newline at end of file diff --git a/benchmarks/string.ts b/benchmarks/string.ts new file mode 100644 index 0000000..b87bff2 --- /dev/null +++ b/benchmarks/string.ts @@ -0,0 +1,93 @@ +/** Benchmarks */ + +/* Requires ------------------------------------------------------------------*/ + +import Benchmark from 'benchmark'; +import {schema} from '../dist/compactr.js'; +import protobuf from 'protobufjs'; +import * as msgpack from '@msgpack/msgpack'; +import {deferred} from './utils.ts'; + +/* Local variables -----------------------------------------------------------*/ + + +let User = schema({ + id: { type: 'integer', format: 'int32'}, + str: { type: 'string' }, + special: { type: 'string' }, +}); + +let root = protobuf.Root.fromJSON({ + nested: { + StringBenchTest: { + fields: { + id: { type: 'uint32', id: 1 }, + str: { type: 'string', id: 2 }, + special: { type: 'string', id: 3 }, + }, + }, + }, +}); +var StringBenchTest = root.lookupType('StringBenchTest'); + +const sizes = { json: 0, compactr: 0, protobuf: 0, msgpack: 0 }; + +const stringSuite = new Benchmark.Suite(); + +/* String suite ---------------------------------------------------------------*/ + +export function init(mult) { + const {promise, resolve} = deferred(); + + stringSuite.add('[String] JSON', strJSON) + .add('[String] Compactr', strCompactr) + .add('[String] Protobuf', strProtobuf) + .add('[String] MsgPack', strMsgPack) + .on('cycle', e => console.log(String(e.target))) + .run({ 'async': true }) + .on('complete', _ => resolve(sizes)); + + + function strJSON() { + let packed, unpacked; + + for(let i = 0; i sizes.json) sizes.json = packed.length; + } + } + + function strCompactr() { + let packed, unpacked; + + for(let i = 0; i sizes.compactr) sizes.compactr = packed.length; + } + } + + function strProtobuf() { + let packed, unpacked; + + for(let i = 0; i sizes.protobuf) sizes.protobuf = packed.length; + } + } + + function strMsgPack() { + let packed, unpacked; + + for(let i = 0; i sizes.msgpack) sizes.msgpack = packed.length; + } + } + + return promise; +} diff --git a/benchmarks/utils.ts b/benchmarks/utils.ts new file mode 100644 index 0000000..03a1740 --- /dev/null +++ b/benchmarks/utils.ts @@ -0,0 +1,18 @@ +export function deferred() { + let resolve: (value?: T | PromiseLike) => void; + let reject: (reason?: any) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + return { promise, resolve, reject }; + } + + export function sequence(array: Array, operation: (item: K, index: number) => Promise): Promise> { + return array.reduce((promiseChain: Promise>, item, index) => { + return promiseChain.then((chainResults: Array) => { + return operation(item, index).then((currentResult) => [...chainResults, currentResult]); + }); + }, Promise.resolve([])); + } \ No newline at end of file diff --git a/benchmarks/uuid.ts b/benchmarks/uuid.ts new file mode 100644 index 0000000..8fe74b7 --- /dev/null +++ b/benchmarks/uuid.ts @@ -0,0 +1,92 @@ +/** Benchmarks */ + +/* Requires ------------------------------------------------------------------*/ + +import Benchmark from 'benchmark'; +import {schema} from '../dist/compactr.js'; +import protobuf from 'protobufjs'; +import * as msgpack from '@msgpack/msgpack'; +import {deferred} from './utils.ts'; +import {randomUUID} from 'crypto'; + +/* Local variables -----------------------------------------------------------*/ + + +let User = schema({ + id: { type: 'integer', format: 'int32'}, + uid: { type: 'string', format: 'uuid' }, +}); + +let root = protobuf.Root.fromJSON({ + nested: { + StringBenchTest: { + fields: { + id: { type: 'uint32', id: 1 }, + uid: { type: 'string', id: 2 }, + }, + }, + }, +}); +var StringBenchTest = root.lookupType('StringBenchTest'); + +const sizes = { json: 0, compactr: 0, protobuf: 0, msgpack: 0 }; + +const stringSuite = new Benchmark.Suite(); + +/* UUID suite ---------------------------------------------------------------*/ + +export function init(mult) { + const {promise, resolve} = deferred(); + + stringSuite.add('[UUID] JSON', strJSON) + .add('[UUID] Compactr', strCompactr) + .add('[UUID] Protobuf', strProtobuf) + .add('[UUID] MsgPack', strMsgPack) + .on('cycle', e => console.log(String(e.target))) + .run({ 'async': true }) + .on('complete', _ => resolve(sizes)); + + + function strJSON() { + let packed, unpacked; + + for(let i = 0; i sizes.json) sizes.json = packed.length; + } + } + + function strCompactr() { + let packed, unpacked; + + for(let i = 0; i sizes.compactr) sizes.compactr = packed.length; + } + } + + function strProtobuf() { + let packed, unpacked; + + for(let i = 0; i sizes.protobuf) sizes.protobuf = packed.length; + } + } + + function strMsgPack() { + let packed, unpacked; + + for(let i = 0; i sizes.msgpack) sizes.msgpack = packed.length; + } + } + + return promise; +} diff --git a/docs/ABOUT.md b/docs/ABOUT.md deleted file mode 100644 index 456e6b1..0000000 --- a/docs/ABOUT.md +++ /dev/null @@ -1,19 +0,0 @@ -# About Compactr.js - -## Why choose Compactr for serialization - -### Who is this for? - -The Compactr protocol is mainly geared to empower high-debit realtime applications such as online games. That said, there's no reason why you couldn't use this in any type of application where serialization occurs like web servers transactions, database gossipping or even data storage. - -### How does this compare? - -Compactr is designed for easier integration with Javascript/Typescript applications. It's performances are very much similar to those of JSON serialization, but the output compares in size with Protobuf. - -It would be possible for compactr to be closer in speed to protobuf if we manage to implement codegen on schema initialization, like protobufjs does. - -Though the area where compactr really shines is in it's ability to handle nested objects and object collections. - -You can store information of any size or with no penalty. - -It is also extremely light in terms of package size, has zero dependencies, goes easy on the CPU and RAM. diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..ba3eafd --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,24 @@ +import eslint from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import jestConfig from 'eslint-plugin-jest'; +import spacing from '@stylistic/eslint-plugin'; + +export default tseslint.config( + eslint.configs.recommended, + tseslint.configs.recommended, + jestConfig.configs['flat/recommended'], + spacing.configs.recommended, + { + rules: { + '@stylistic/semi': [2, 'always'], + '@typescript-eslint/no-explicit-any': 0, + '@typescript-eslint/no-require-imports': 1, + 'jest/no-done-callback': 0, + 'jest/no-conditional-expect': 0, + 'no-prototype-builtins': 'warn', + }, + }, + { + ignores: ['**/dist', '**/benchmarks', '**/*.js'], + }, +); diff --git a/index.js b/index.js deleted file mode 100644 index 2c75d03..0000000 --- a/index.js +++ /dev/null @@ -1,9 +0,0 @@ -/** Entry point */ - -/* Requires ------------------------------------------------------------------*/ - -const schema = require('./src/schema'); - -/* Exports -------------------------------------------------------------------*/ - -module.exports = { schema }; diff --git a/package.json b/package.json index a539dcd..6f36a7d 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,19 @@ { "name": "compactr", - "version": "2.4.2", + "version": "3.0.0", "description": "Schema based serialization made easy", - "main": "index.js", + "main": "dist/compactr.js", "scripts": { "lint": "eslint .", "lint:fix": "eslint . --fix", - "test": "mocha tests/index.js --exit", - "bench": "node benchmarks/array.js && node benchmarks/boolean.js && node benchmarks/double.js && node benchmarks/integer.js && node benchmarks/object.js && node benchmarks/string.js" + "test": "jest ./tests", + "build": "npm run clean && rollup -c rollup.config.mjs", + "clean": "rm -rf ./dist/*", + "bench": "node ./benchmarks/index.ts", + "prepublishOnly": "npm run build" + }, + "engines": { + "node": ">=20" }, "funding": { "type": "Github", @@ -18,21 +24,22 @@ "url": "git+https://github.com/compactr/compactr-js.git" }, "keywords": [ - "compactr", - "compact", - "encode", - "encoding", - "serializing", - "buffer", - "byte", - "decode", - "decoding", + "serialize", + "openapi", + "swagger", "compress", - "serialize" + "protobuf", + "encode", + "msgpack" ], + "files": [ + "dist", + "types.d.ts" + ], + "typings": "./types.d.ts", "husky": { "hooks": { - "pre-commit": "yarn lint" + "pre-commit": "npm run lint" } }, "author": "frederic charette ", @@ -41,12 +48,44 @@ "url": "https://github.com/compactr/compactr.js/issues" }, "homepage": "https://github.com/compactr/compactr.js#readme", + "jest": { + "preset": "ts-jest", + "testEnvironment": "node", + "testMatch": [ + "**/tests/**/*.ts", + "**/tests/**/*.js" + ], + "transform": { + "^.+\\.tsx?$": [ + "ts-jest", + { + "diagnostics": false + } + ], + "^.+\\.jsx?$": [ + "ts-jest", + { + "diagnostics": false + } + ] + } + }, "devDependencies": { + "@msgpack/msgpack": "^3.0.0", + "@rollup/plugin-node-resolve": "^16.0.0", + "@rollup/plugin-sucrase": "^5.0.0", + "@stylistic/eslint-plugin": "^5.5.0", + "@types/jest": "^30.0.0", + "@types/node": "^24.9.0", "benchmark": "^2.1.4", - "chai": "^4.2.0", - "eslint": "^6.7.1", - "husky": "^3.1.0", - "mocha": "^6.2.0", - "protobufjs": "^6.8.8" + "eslint": "^9.38.0", + "eslint-plugin-jest": "^29.0.0", + "husky": "^9.1.0", + "jest": "^30.2.0", + "protobufjs": "^7.5.0", + "rollup": "^4.52.0", + "ts-jest": "^29.4.0", + "typescript": "^5.9.0", + "typescript-eslint": "^8.46.0" } } diff --git a/rollup.config.mjs b/rollup.config.mjs new file mode 100644 index 0000000..69cce84 --- /dev/null +++ b/rollup.config.mjs @@ -0,0 +1,22 @@ +import resolve from '@rollup/plugin-node-resolve'; +import sucrase from '@rollup/plugin-sucrase'; + +export default { + input: 'src/index.ts', + plugins: [ + resolve({ + extensions: ['.ts'], + preferBuiltins: true, + browser: false, + }), + sucrase({ + include: ['src/**'], + transforms: ['typescript'], + }), + ], + output: { + file: 'dist/compactr.js', + name: 'compactr', + format: 'umd', + }, +}; diff --git a/src/buffer-utils.ts b/src/buffer-utils.ts new file mode 100644 index 0000000..2666bcc --- /dev/null +++ b/src/buffer-utils.ts @@ -0,0 +1,70 @@ +import { matchesVariant } from './variant-matcher'; + +const VARIANT_BASE = 0x01; + +export function writeSize(buffer: Uint8Array, pos: number, size: number, count: number): number { + if (count === 1) { + buffer[pos] = size & 0xff; + return pos + 1; + } + if (count === 2) { + buffer[pos] = size >> 8; + buffer[pos + 1] = size & 0xff; + return pos + 2; + } + if (count === 4) { + buffer[pos] = size >> 24; + buffer[pos + 1] = size >> 16; + buffer[pos + 2] = size >> 8; + buffer[pos + 3] = size & 0xff; + return pos + 4; + } + return pos; +} + +export function writeFieldWithSize( + buffer: Uint8Array, + pos: number, + value: any, + field: any, +): number { + if (field.size) { + pos = writeSize(buffer, pos, field.size, field.count); + return field.transformIn(value, buffer, pos); + } + + const sizePos = pos; + pos += field.count; + const dataStart = pos; + pos = field.transformIn(value, buffer, pos); + const size = pos - dataStart; + writeSize(buffer, sizePos, size, field.count); + return pos; +} + +export function processVariantWrite( + buffer: Uint8Array, + pos: number, + data: any, + field: any, + contextName?: string, +): number { + let variantIndex = -1; + let variantField = null; + + for (let v = 0; v < field.variants.length; v++) { + if (matchesVariant(data, field.variants[v])) { + variantIndex = v; + variantField = field.variants[v]; + break; + } + } + + if (variantIndex === -1) { + const context = contextName || 'Data'; + throw new Error(`${context} does not match any variant`); + } + + buffer[pos++] = VARIANT_BASE + variantIndex; + return writeFieldWithSize(buffer, pos, data, variantField); +} diff --git a/src/converter.js b/src/converter.js deleted file mode 100644 index 36859e3..0000000 --- a/src/converter.js +++ /dev/null @@ -1,61 +0,0 @@ -/** Type Coersion utilities */ - -/* Methods -------------------------------------------------------------------*/ - -/** @private */ -function int8(value) { - return Number(value) & 0xff; -} - -/** @private */ -function int16(value) { - return Number(value) & 0xffff; -} - -/** @private */ -function int32(value) { - return Number(value) & 0xffffffff; -} - -/** @private */ -function double(value) { - const ret = Number(value); - return (Number.isFinite(ret)) ? ret : 0; -} - -/** @private */ -function string(value) { - return '' + value; -} - -/** @private */ -function boolean(value) { - return !!value; -} - -/** @private */ -function object(value) { - return (value.constructor === Object) ? value : {}; -} - -/** @private */ -function array(value) { - return (value.concat !== undefined) ? value : [value]; -} - -/* Exports -------------------------------------------------------------------*/ - -module.exports = { - int8, - int16, - int32, - number: double, - double, - string, - char8: string, - char16: string, - char32: string, - boolean, - array, - object, -}; diff --git a/src/converter.ts b/src/converter.ts new file mode 100644 index 0000000..b89b8de --- /dev/null +++ b/src/converter.ts @@ -0,0 +1,122 @@ +function int32(value) { + return Number(value) & 0xffffffff; +} + +function float(value) { + const ret = Number(value); + return (Number.isFinite(ret)) ? ret : 0; +} + +function double(value) { + const ret = Number(value); + return (Number.isFinite(ret)) ? ret : 0; +} + +function int64(value) { + const ret = Number(value); + return (Number.isFinite(ret)) ? Math.trunc(ret) : 0; +} + +function string(value) { + return '' + value; +} + +function uuid(value) { + const str = '' + value; + const normalized = str.toLowerCase().replace(/-/g, ''); + if (!/^[0-9a-f]{32}$/.test(normalized)) { + throw new Error('Invalid UUID format'); + } + return [ + normalized.substr(0, 8), + normalized.substr(8, 4), + normalized.substr(12, 4), + normalized.substr(16, 4), + normalized.substr(20, 12), + ].join('-'); +} + +function ipv4(value) { + const str = '' + value; + const parts = str.split('.'); + if (parts.length !== 4) { + throw new Error('Invalid IPv4 format'); + } + for (let i = 0; i < 4; i++) { + const num = parseInt(parts[i], 10); + if (isNaN(num) || num < 0 || num > 255) { + throw new Error('Invalid IPv4 format'); + } + } + return str; +} + +function ipv6(value) { + const str = '' + value; + if (!/^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/.test(str) && !/^::$/.test(str)) { + throw new Error('Invalid IPv6 format'); + } + return str.toLowerCase(); +} + +function date(value) { + const str = '' + value; + if (!/^\d{4}-\d{2}-\d{2}$/.test(str)) { + throw new Error('Invalid date format, expected YYYY-MM-DD'); + } + return str; +} + +function dateTime(value) { + const str = '' + value; + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z?$/.test(str)) { + throw new Error('Invalid date-time format, expected YYYY-MM-DDTHH:mm:ss.sssZ'); + } + return str; +} + +function binary(value) { + if (Buffer.isBuffer(value)) { + return value.toString('base64'); + } + + if (value instanceof Uint8Array) { + return Buffer.from(value).toString('base64'); + } + + if (typeof value === 'string') { + const buffer = Buffer.from(value, 'base64'); + return buffer.toString('base64'); + } + + throw new Error('Invalid binary format: expected Buffer, Uint8Array, or base64 string'); +} + +function boolean(value) { + return !!value; +} + +function object(value) { + return (value.constructor === Object) ? value : {}; +} + +function array(value) { + return (value.concat !== undefined) ? value : [value]; +} + +export default { + int32, + int64, + float, + double, + string, + uuid, + ipv4, + ipv6, + date, + 'date-time': dateTime, + binary, + boolean, + array, + object, +}; diff --git a/src/decoder.js b/src/decoder.js deleted file mode 100644 index 8be8685..0000000 --- a/src/decoder.js +++ /dev/null @@ -1,128 +0,0 @@ -/** Decoding utilities */ - -/* Local variables -----------------------------------------------------------*/ - -const fromChar = String.fromCharCode; - -/* Methods -------------------------------------------------------------------*/ - -/** @private */ -function boolean(bytes) { - return !!bytes[0]; -} - -/** @private */ -function int8(bytes) { - return (!(bytes[0] & 0x80))?bytes[0]:((0xff - bytes[0] + 1) * -1); -} - -/** @private */ -function int16(bytes) { - const val = (bytes[0] << 8) | bytes[1]; - return (val & 0x8000) ? val | 0xFFFF0000 : val; -} - -/** @private */ -function int32(bytes) { - return (bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | (bytes[3]) -} - -function uint8(bytes) { - return bytes[0]; -} - -function uint16(bytes) { - return bytes[0] << 8 | bytes[1]; -} - -/** @private */ -function unsigned(bytes) { - if (bytes.length === 1) return uint8(bytes); - if (bytes.length === 2) return uint16(bytes); - return int32(bytes); -} - -/** @private */ -function string(bytes) { - let res = []; - for (let i = 0; i < bytes.length; i += 2) { - res.push(unsigned([bytes[i], bytes[i + 1]])); - } - return fromChar(...res); -} - -/** @private */ -function char8(bytes) { - return fromChar(...bytes); -} - -/** @private */ -function char32(bytes) { - let res = []; - for (let i = 0; i < bytes.length; i += 4) { - res.push(int32([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]])); - } - return fromChar(...res); -} - -/** @private */ -function array(schema, bytes) { - const ret = []; - for (let i = 0; i < bytes.length;) { - const size = unsigned(bytes.slice(i, i + schema.count)); - i = (i + schema.count); - ret.push(schema.transformOut(bytes.slice(i, i + size))); - i = (i + size); - } - - return ret; -} - -/** @private */ -function object(schema, bytes) { - return schema.read(bytes); -} - -/** - * Credit to @feross' ieee754 module - * @private - */ -function double(bytes) { - let s = bytes[0]; - let e = (s & 127); - e = e * 256 + bytes[1]; - let m = e & 15; - s >>= 7; - e >>= 4; - for (let im = 2; im <= 7; im++) { - m = m * 256 + bytes[im]; - } - if (e === 0) e = -1022; - else if (e === 2047) return NaN; - else { - m += 4503599627370496; - e -= 1023; - } - return (s ? -1 : 1) * m * Math.pow(2, e - 52); -} - -/* Exports -------------------------------------------------------------------*/ - -module.exports = { - boolean, - number: double, - int8, - int16, - int32, - double, - string, - char8, - char16: string, - char32, - array, - object, - unsigned, - unsigned8: uint8, - unsigned16: uint16, - unsigned32: int32, -}; \ No newline at end of file diff --git a/src/decoder.ts b/src/decoder.ts new file mode 100644 index 0000000..9d6da12 --- /dev/null +++ b/src/decoder.ts @@ -0,0 +1,302 @@ +export const NULL_INDICATOR = 0x00; +export const VARIANT_BASE = 0x01; + +const floatBuffer = new Float32Array(1); +const floatBytes = new Uint8Array(floatBuffer.buffer); +const doubleBuffer = new Float64Array(1); +const doubleBytes = new Uint8Array(doubleBuffer.buffer); + +function boolean(bytes, offset = 0) { + return !!bytes[offset]; +} + +function int32(bytes, offset = 0) { + return (bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | (bytes[offset + 3]); +} + +function uint8(bytes, offset = 0) { + return bytes[offset]; +} + +function uint16(bytes, offset = 0) { + return bytes[offset] << 8 | bytes[offset + 1]; +} + +function unsigned(bytes, offset = 0, length?) { + const len = length !== undefined ? length : bytes.length - offset; + if (len === 1) return uint8(bytes, offset); + if (len === 2) return uint16(bytes, offset); + return int32(bytes, offset); +} + +function string(bytes, offset = 0, length?) { + const len = length !== undefined ? length : bytes.length - offset; + const chars = []; + const end = offset + len; + + for (let i = offset; i < end;) { + const byte1 = bytes[i++]; + + if (byte1 < 0x80) { + chars.push(byte1); + } + else if ((byte1 & 0xE0) === 0xC0) { + const byte2 = bytes[i++]; + chars.push(((byte1 & 0x1F) << 6) | (byte2 & 0x3F)); + } + else if ((byte1 & 0xF0) === 0xE0) { + const byte2 = bytes[i++]; + const byte3 = bytes[i++]; + chars.push(((byte1 & 0x0F) << 12) | ((byte2 & 0x3F) << 6) | (byte3 & 0x3F)); + } + else if ((byte1 & 0xF8) === 0xF0) { + const byte2 = bytes[i++]; + const byte3 = bytes[i++]; + const byte4 = bytes[i++]; + let code = ((byte1 & 0x07) << 18) | ((byte2 & 0x3F) << 12) | ((byte3 & 0x3F) << 6) | (byte4 & 0x3F); + code -= 0x10000; + chars.push(0xD800 | (code >> 10)); + chars.push(0xDC00 | (code & 0x3FF)); + } + } + + return String.fromCharCode.apply(null, chars); +} + +function uuid(bytes, offset = 0, length?) { + const len = length !== undefined ? length : bytes.length - offset; + if (len !== 16) { + throw new Error('Invalid UUID byte length'); + } + + const hex = '0123456789abcdef'; + let result = ''; + + for (let i = 0; i < 16; i++) { + const byte = bytes[offset + i]; + result += hex[byte >> 4] + hex[byte & 0x0f]; + + if (i === 3 || i === 5 || i === 7 || i === 9) { + result += '-'; + } + } + + return result; +} + +function ipv4(bytes, offset = 0, length?) { + const len = length !== undefined ? length : bytes.length - offset; + if (len !== 4) { + throw new Error('Invalid IPv4 byte length'); + } + + return `${bytes[offset]}.${bytes[offset + 1]}.${bytes[offset + 2]}.${bytes[offset + 3]}`; +} + +function ipv6(bytes, offset = 0, length?) { + const len = length !== undefined ? length : bytes.length - offset; + if (len !== 16) { + throw new Error('Invalid IPv6 byte length'); + } + + const groups = new Array(8); + let longestZeroStart = -1; + let longestZeroLength = 0; + let currentZeroStart = -1; + let currentZeroLength = 0; + + for (let i = 0; i < 8; i++) { + const value = (bytes[offset + i * 2] << 8) | bytes[offset + i * 2 + 1]; + groups[i] = value; + + if (value === 0) { + if (currentZeroStart === -1) { + currentZeroStart = i; + currentZeroLength = 1; + } + else { + currentZeroLength++; + } + } + else { + if (currentZeroLength > longestZeroLength) { + longestZeroStart = currentZeroStart; + longestZeroLength = currentZeroLength; + } + currentZeroStart = -1; + currentZeroLength = 0; + } + } + + if (currentZeroLength > longestZeroLength) { + longestZeroStart = currentZeroStart; + longestZeroLength = currentZeroLength; + } + + let result = ''; + + if (longestZeroLength > 0) { + for (let i = 0; i < longestZeroStart; i++) { + if (i > 0) result += ':'; + result += groups[i].toString(16); + } + + result += '::'; + + const afterStart = longestZeroStart + longestZeroLength; + for (let i = afterStart; i < 8; i++) { + if (i > afterStart) result += ':'; + result += groups[i].toString(16); + } + } + else { + for (let i = 0; i < 8; i++) { + if (i > 0) result += ':'; + result += groups[i].toString(16); + } + } + + return result; +} + +function date(bytes, offset = 0, length?) { + const len = length !== undefined ? length : bytes.length - offset; + if (len !== 4) { + throw new Error('Invalid date byte length'); + } + + const year = (bytes[offset] << 8) | bytes[offset + 1]; + const month = String(bytes[offset + 2]).padStart(2, '0'); + const day = String(bytes[offset + 3]).padStart(2, '0'); + + return `${year}-${month}-${day}`; +} + +function dateTime(bytes, offset = 0, length?) { + const len = length !== undefined ? length : bytes.length - offset; + if (len !== 9) { + throw new Error('Invalid date-time byte length'); + } + + const year = (bytes[offset] << 8) | bytes[offset + 1]; + const month = String(bytes[offset + 2]).padStart(2, '0'); + const day = String(bytes[offset + 3]).padStart(2, '0'); + const hour = String(bytes[offset + 4]).padStart(2, '0'); + const minute = String(bytes[offset + 5]).padStart(2, '0'); + const second = String(bytes[offset + 6]).padStart(2, '0'); + const millisecond = ((bytes[offset + 7] << 8) | bytes[offset + 8]).toString().padStart(3, '0'); + + return `${year}-${month}-${day}T${hour}:${minute}:${second}.${millisecond}Z`; +} + +function binary(bytes, offset = 0, length?) { + const len = length !== undefined ? length : bytes.length - offset; + if (offset === 0 && len === bytes.length) { + return Buffer.from(bytes).toString('base64'); + } + return Buffer.from(bytes.subarray(offset, offset + len)).toString('base64'); +} + +function array(schema, bytes, offset = 0, length?) { + const len = length !== undefined ? length : bytes.length - offset; + const ret = []; + const end = offset + len; + + for (let i = offset; i < end;) { + if (schema.nullable || schema.variants) { + const discriminator = bytes[i]; + i++; + + if (discriminator === 0x00) { + ret.push(null); + continue; + } + + if (schema.variants) { + const variantIndex = discriminator - 0x01; + const variantField = schema.variants[variantIndex]; + + if (!variantField) { + throw new Error(`Invalid variant discriminator: ${discriminator}`); + } + + const size = unsigned(bytes, i, variantField.count); + i += variantField.count; + + ret.push(variantField.transformOut(bytes, i, size)); + i += size; + continue; + } + } + + const size = unsigned(bytes, i, schema.count); + i += schema.count; + + ret.push(schema.transformOut(bytes, i, size)); + i += size; + } + + return ret; +} + +function object(schema, bytes, offset = 0, length?) { + if (offset === 0 && length === undefined) { + return schema.read(bytes); + } + + const len = length !== undefined ? length : bytes.length - offset; + + if (schema.readFromOffset) { + return schema.readFromOffset(bytes, offset, len); + } + + if (bytes.subarray) { + return schema.read(bytes.subarray(offset, offset + len)); + } + + return schema.read(bytes.slice(offset, offset + len)); +} + +function float(bytes, offset = 0) { + floatBytes[0] = bytes[offset + 3]; + floatBytes[1] = bytes[offset + 2]; + floatBytes[2] = bytes[offset + 1]; + floatBytes[3] = bytes[offset]; + + return floatBuffer[0]; +} + +function double(bytes, offset = 0) { + doubleBytes[0] = bytes[offset + 7]; + doubleBytes[1] = bytes[offset + 6]; + doubleBytes[2] = bytes[offset + 5]; + doubleBytes[3] = bytes[offset + 4]; + doubleBytes[4] = bytes[offset + 3]; + doubleBytes[5] = bytes[offset + 2]; + doubleBytes[6] = bytes[offset + 1]; + doubleBytes[7] = bytes[offset]; + + return doubleBuffer[0]; +} + +function int64(bytes, offset = 0) { + return double(bytes, offset); +} + +export default { + boolean, + int32, + int64, + float, + double, + string, + uuid, + ipv4, + ipv6, + date, + 'date-time': dateTime, + binary, + array, + object, + unsigned, +}; diff --git a/src/encoder.js b/src/encoder.js deleted file mode 100644 index 91013b6..0000000 --- a/src/encoder.js +++ /dev/null @@ -1,171 +0,0 @@ -/** Encoding utilities */ - -/* Local variables -----------------------------------------------------------*/ - -const intMap = [null, unsigned8, unsigned16, null, unsigned32]; -const abs = Math.abs; -const pow = Math.pow; -const ln2 = Math.LN2; -const log = Math.log; -const floor = Math.floor; -const bias = pow(2, 52); -const eIn = pow(2, -1022); -const eOut = pow(2, 1022) * bias; - -/* Methods -------------------------------------------------------------------*/ - -/** @private */ -function boolean(val) { - return [val ? 1 : 0]; -} - -/** @private */ -function int8(val) { - return [(val < 0) ? 256 + val : val]; -} - -/** @private */ -function int16(val) { - if (val < 0) val = 0xffff + val + 1; - return [val >> 8, val & 0xff]; -} - -/** @private */ -function int32(val) { - if (val < 0) val = 0xffffffff + val + 1; - return [val >> 24, val >> 16, val >> 8, val & 0xff]; -} - -/** @private */ -function unsigned8(val) { - return [val & 0xff]; -} - -/** @private */ -function unsigned16(val) { - return [val >> 8, val & 0xff]; -} - -/** @private */ -function unsigned32(val) { - return [val >> 24, val >> 16, val >> 8, val & 0xff]; -} - -/** @private */ -function char8(val) { - const chars = []; - for (let i = 0; i < val.length; i++) { - chars.push(val.charCodeAt(i) % 0xff); - } - - return chars; -} - -/** @private */ -function string(encoding, val) { - const chars = []; - for (let i = 0; i < val.length; i++) { - chars.push(...encoding(val.charCodeAt(i))); - } - - return chars; -} - -/** @private */ -function array(schema, val) { - const ret = []; - for (let i = 0; i < val.length; i++) { - let encoded = schema.transformIn(val[i]); - ret.push(...schema.getSize(encoded.length), ...encoded); - } - return ret; -} - -/** @private */ -function object(schema, val) { - return schema.write(val).typedArray(); -} - -/** - * Credit to @feross' ieee754 module - * @private - */ -function double(val) { - let buffer = []; - let e, m, c; - let eMax = 2047; - let eBias = 1023; - let rt = 0; - let i = 7; - let d = -1; - let s = val <= 0 ? 1 : 0; - val = abs(val); - e = floor(log(val) / ln2); - c = pow(2, -e); - if (val * c < 1) { - e--; - c *= 2; - } - - if (e + eBias >= 1) val += rt / c; - else val += rt * eIn; - - if (val * c >= 2) { - e++; - c /= 2; - } - - if (e + eBias >= eMax) { - m = 0; - e = eMax; - } else if (e + eBias >= 1) { - m = (val * c - 1) * bias - e = e + eBias; - } else { - m = val * eOut; - e = 0; - } - - for (let a = 0; a < 6; a++) { - buffer[i] = m & 0xff; - i += d; - m /= 256; - } - - e = (e << 4) | m; - for (let b = 0; b < 2; b++) { - buffer[i] = e & 0xff; - i += d; - e /= 256; - } - - buffer[i - d] |= s * 128; - - return buffer; -} - -/** @private */ -function getSize(count, byteLength) { - return intMap[count](byteLength); -} - -/* Exports -------------------------------------------------------------------*/ - -module.exports = { - boolean, - number: double, - int8, - int16, - int32, - double, - string: string.bind(null, unsigned16), - char8, - char16: string.bind(null, unsigned16), - char32: string.bind(null, unsigned32), - array, - object, - getSize, - unsigned8, - unsigned16, - unsigned32, -}; \ No newline at end of file diff --git a/src/encoder.ts b/src/encoder.ts new file mode 100644 index 0000000..1c28f92 --- /dev/null +++ b/src/encoder.ts @@ -0,0 +1,319 @@ +import { writeFieldWithSize, processVariantWrite } from './buffer-utils'; + +export const NULL_INDICATOR = 0x00; +export const VARIANT_BASE = 0x01; + +const floatBuffer = new Float32Array(1); +const floatBytes = new Uint8Array(floatBuffer.buffer); +const doubleBuffer = new Float64Array(1); +const doubleBytes = new Uint8Array(doubleBuffer.buffer); + +function boolean(val, buffer, pos) { + buffer[pos] = val ? 1 : 0; + return pos + 1; +} + +function int32(val, buffer, pos) { + if (val < 0) val = 0xffffffff + val + 1; + buffer[pos] = val >> 24; + buffer[pos + 1] = val >> 16; + buffer[pos + 2] = val >> 8; + buffer[pos + 3] = val & 0xff; + return pos + 4; +} + +function string(val, buffer, pos) { + const bytesWritten = Buffer.prototype.write.call(buffer, val, pos, undefined, 'utf8'); + return pos + bytesWritten; +} + +function hexCharToNum(char) { + const code = char.charCodeAt(0); + if (code >= 48 && code <= 57) return code - 48; // 0-9 + if (code >= 97 && code <= 102) return code - 87; // a-f + if (code >= 65 && code <= 70) return code - 55; // A-F + throw new Error('Invalid hex character: ' + char); +} + +function uuid(val, buffer, pos) { + if (val.length !== 36) { + throw new Error('Invalid UUID format: expected 36 characters'); + } + + let byteIdx = pos; + + for (let i = 0; i < val.length; i++) { + const char = val[i]; + if (char === '-') continue; + + const high = hexCharToNum(val[i]); + const low = hexCharToNum(val[i + 1]); + buffer[byteIdx++] = (high << 4) | low; + i++; + } + + if (byteIdx - pos !== 16) { + throw new Error('Invalid UUID format: incorrect number of hex digits'); + } + + return byteIdx; +} + +function ipv4(val, buffer, pos) { + const parts = val.split('.'); + if (parts.length !== 4) { + throw new Error('Invalid IPv4 format'); + } + + for (let i = 0; i < 4; i++) { + const num = parseInt(parts[i], 10); + if (isNaN(num) || num < 0 || num > 255) { + throw new Error('Invalid IPv4 format'); + } + buffer[pos + i] = num; + } + + return pos + 4; +} + +function ipv6(val, buffer, pos) { + let byteIdx = 0; + + const doubleColonPos = val.indexOf('::'); + + if (doubleColonPos !== -1) { + let i = 0; + while (i < doubleColonPos) { + let hexStr = ''; + while (i < doubleColonPos && val[i] !== ':') { + hexStr += val[i]; + i++; + } + if (hexStr) { + const num = parseInt(hexStr, 16); + if (isNaN(num) || num < 0 || num > 0xffff) { + throw new Error('Invalid IPv6 format'); + } + buffer[pos + byteIdx++] = num >> 8; + buffer[pos + byteIdx++] = num & 0xff; + } + i++; + } + + const leftGroups = byteIdx / 2; + + i = doubleColonPos + 2; + const rightStart = []; + while (i < val.length) { + let hexStr = ''; + while (i < val.length && val[i] !== ':') { + hexStr += val[i]; + i++; + } + if (hexStr) { + const num = parseInt(hexStr, 16); + if (isNaN(num) || num < 0 || num > 0xffff) { + throw new Error('Invalid IPv6 format'); + } + rightStart.push(num >> 8, num & 0xff); + } + i++; + } + + const rightGroups = rightStart.length / 2; + const zeroGroups = 8 - leftGroups - rightGroups; + + for (let z = 0; z < zeroGroups * 2; z++) { + buffer[pos + byteIdx++] = 0; + } + + for (let r = 0; r < rightStart.length; r++) { + buffer[pos + byteIdx++] = rightStart[r]; + } + } + else { + let i = 0; + let groupCount = 0; + while (i < val.length) { + let hexStr = ''; + while (i < val.length && val[i] !== ':') { + hexStr += val[i]; + i++; + } + if (hexStr) { + const num = parseInt(hexStr || '0', 16); + if (isNaN(num) || num < 0 || num > 0xffff) { + throw new Error('Invalid IPv6 format'); + } + buffer[pos + byteIdx++] = num >> 8; + buffer[pos + byteIdx++] = num & 0xff; + groupCount++; + } + i++; + } + + if (groupCount !== 8) { + throw new Error('Invalid IPv6 format: expected 8 groups'); + } + } + + if (byteIdx !== 16) { + throw new Error('Invalid IPv6 format: incorrect byte count'); + } + + return pos + 16; +} + +function date(val, buffer, pos) { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(val); + if (!match) { + throw new Error('Invalid date format, expected YYYY-MM-DD'); + } + + const year = parseInt(match[1], 10); + const month = parseInt(match[2], 10); + const day = parseInt(match[3], 10); + + buffer[pos] = year >> 8; + buffer[pos + 1] = year & 0xff; + buffer[pos + 2] = month; + buffer[pos + 3] = day; + return pos + 4; +} + +function dateTime(val, buffer, pos) { + const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{3}))?Z?$/.exec(val); + if (!match) { + throw new Error('Invalid date-time format, expected YYYY-MM-DDTHH:mm:ss.sssZ'); + } + + const year = parseInt(match[1], 10); + const month = parseInt(match[2], 10); + const day = parseInt(match[3], 10); + const hour = parseInt(match[4], 10); + const minute = parseInt(match[5], 10); + const second = parseInt(match[6], 10); + const millisecond = match[7] ? parseInt(match[7], 10) : 0; + + buffer[pos] = year >> 8; + buffer[pos + 1] = year & 0xff; + buffer[pos + 2] = month; + buffer[pos + 3] = day; + buffer[pos + 4] = hour; + buffer[pos + 5] = minute; + buffer[pos + 6] = second; + buffer[pos + 7] = millisecond >> 8; + buffer[pos + 8] = millisecond & 0xff; + return pos + 9; +} + +function binary(val, buffer, pos) { + if (Buffer.isBuffer(val)) { + val.copy(buffer, pos); + return pos + val.length; + } + + if (val instanceof Uint8Array) { + buffer.set(val, pos); + return pos + val.length; + } + + if (typeof val === 'string') { + const bytesWritten = Buffer.from(val, 'base64').copy(buffer, pos); + return pos + bytesWritten; + } + + throw new Error('Binary format requires Buffer, Uint8Array, or base64 string'); +} + +function array(schema, val, buffer, pos) { + for (let i = 0; i < val.length; i++) { + const item = val[i]; + + if (schema.nullable && item === null) { + buffer[pos++] = NULL_INDICATOR; + continue; + } + + if (schema.variants) { + pos = processVariantWrite(buffer, pos, item, schema, 'Array item'); + continue; + } + + if (schema.nullable) { + buffer[pos++] = VARIANT_BASE; + } + + pos = writeFieldWithSize(buffer, pos, item, schema); + } + + return pos; +} + +function object(schema, val, buffer, pos) { + return schema.writeToBuffer(val, buffer, pos); +} + +function float(val, buffer, pos) { + floatBuffer[0] = val; + + buffer[pos] = floatBytes[3]; + buffer[pos + 1] = floatBytes[2]; + buffer[pos + 2] = floatBytes[1]; + buffer[pos + 3] = floatBytes[0]; + return pos + 4; +} + +function double(val, buffer, pos) { + doubleBuffer[0] = val; + + buffer[pos] = doubleBytes[7]; + buffer[pos + 1] = doubleBytes[6]; + buffer[pos + 2] = doubleBytes[5]; + buffer[pos + 3] = doubleBytes[4]; + buffer[pos + 4] = doubleBytes[3]; + buffer[pos + 5] = doubleBytes[2]; + buffer[pos + 6] = doubleBytes[1]; + buffer[pos + 7] = doubleBytes[0]; + return pos + 8; +} + +function int64(val, buffer, pos) { + return double(val, buffer, pos); +} + +function getSize(count, byteLength) { + if (count === 1) { + return Buffer.from([byteLength & 0xff]); + } + if (count === 2) { + return Buffer.from([byteLength >> 8, byteLength & 0xff]); + } + if (count === 4) { + return Buffer.from([ + byteLength >> 24, + (byteLength >> 16) & 0xff, + (byteLength >> 8) & 0xff, + byteLength & 0xff, + ]); + } + return Buffer.from([]); +} + +export default { + boolean, + int32, + int64, + float, + double, + string, + uuid, + ipv4, + ipv6, + date, + 'date-time': dateTime, + binary, + array, + object, + getSize, +}; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..bd6fa05 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,3 @@ +import schema from './schema'; + +export { schema }; diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..674eb3e --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,15 @@ +declare const window: any; + +let enabled: boolean = null; +const prefix = `COMPACTR${typeof process === 'object' && ` (pid:${process.pid})`}`; + +export function log(msg: string): void { + if (enabled === null) { + enabled = ( + (typeof process === 'object' && process.env.NODE_DEBUG) + || (typeof window === 'object' && window.DEBUG) + || '' + ).indexOf('compactr') > -1; + } + if (enabled === true) console.log(`${prefix}: ${msg}`); +} diff --git a/src/reader.js b/src/reader.js deleted file mode 100644 index be52414..0000000 --- a/src/reader.js +++ /dev/null @@ -1,81 +0,0 @@ -/** Data reader component */ - -/* Requires ------------------------------------------------------------------*/ - -const Decoder = require('./decoder'); - -/* Methods -------------------------------------------------------------------*/ - -function Reader(scope) { - - /** - * Decodes an encoded buffer. Requires header bytes. - * @param {Buffer} bytes - * @returns {Object} The decoded buffer - */ - function read(bytes) { - readHeader(bytes); - return readContent(bytes, scope.contentBegins); - } - - /** - * Reads only the header of an encoded buffer - * @param {*} bytes - */ - function readHeader(bytes) { - scope.header = []; - let caret = 1; - const keys = bytes[0]; - for (let i = 0; i < keys; i++) { - caret = readKey(bytes, caret, i); - } - scope.contentBegins = caret; - - return this; - } - - /** @private */ - function readKey(bytes, caret, index) { - const key = getSchemaDef(bytes[caret]); - - scope.header[index] = { - key, - size: key.size || Decoder.unsigned(bytes.slice(caret + 1, caret + key.count + 1)), - }; - return caret + key.count + 1; - } - - /** @private */ - function getSchemaDef(index) { - for (let i = 0; i < scope.items.length; i++) { - if (scope.indices[scope.items[i]].index === index) return scope.indices[scope.items[i]]; - } - } - - /** - * Reads only a content buffer and returns an object with the decoded values - * @param {Buffer} bytes The content buffer - * @param {Integer} caret The content bytes offset, if the bytes also include an header - * @returns {Object} An object with the decoded values - */ - function readContent(bytes, caret) { - caret = caret || 0; - const ret = {}; - if (scope.options.keyOrder === true) { - for (let i = 0; i < scope.items.length; i++) { - ret[scope.items[i]] = undefined; - } - } - for (let i = 0; i < scope.header.length; i++) { - ret[scope.header[i].key.name] = scope.header[i].key.transformOut(bytes.slice(caret, caret + scope.header[i].size)); - caret += scope.header[i].size; - } - return ret; - } - - return { read, readHeader, readContent }; -} - -/* Exports -------------------------------------------------------------------*/ - -module.exports = Reader; diff --git a/src/reader.ts b/src/reader.ts new file mode 100644 index 0000000..adb37ff --- /dev/null +++ b/src/reader.ts @@ -0,0 +1,78 @@ +import Decoder, { NULL_INDICATOR, VARIANT_BASE } from './decoder'; + +export default function Reader(scope) { + function read(bytes, offset = 0, length?) { + const ret = {}; + const end = length !== undefined ? offset + length : bytes.length; + let caret = offset + 1; + const fieldCount = bytes[offset]; + + for (let i = 0; i < fieldCount; i++) { + if (caret >= end) break; + + const fieldIndex = bytes[caret]; + caret++; + + const field = scope.indexToField[fieldIndex]; + if (!field) { + throw new Error(`Unknown field index: ${fieldIndex}`); + } + + if (field.nullable || field.variants) { + const discriminatorByte = bytes[caret]; + caret++; + + if (discriminatorByte === NULL_INDICATOR) { + ret[field.name] = null; + continue; + } + + if (field.variants) { + const variantIndex = discriminatorByte - VARIANT_BASE; + if (variantIndex < 0 || variantIndex >= field.variants.length) { + throw new Error(`Invalid variant discriminator: ${discriminatorByte}`); + } + + const variant = field.variants[variantIndex]; + const size = variant.size || readSize(bytes, caret, variant.count); + caret += variant.count; + + ret[field.name] = variant.transformOut(bytes, caret, size); + caret += size; + continue; + } + + const size = field.size || readSize(bytes, caret, field.count); + caret += field.count; + + ret[field.name] = field.transformOut(bytes, caret, size); + caret += size; + } + else { + const size = field.size || readSize(bytes, caret, field.count); + caret += field.count; + + ret[field.name] = field.transformOut(bytes, caret, size); + caret += size; + } + } + + return ret; + } + + function readSize(bytes, offset, count) { + if (count === 1) return bytes[offset]; + if (count === 2) return (bytes[offset] << 8) | bytes[offset + 1]; + if (count === 4) { + return (bytes[offset] << 24) | (bytes[offset + 1] << 16) + | (bytes[offset + 2] << 8) | bytes[offset + 3]; + } + return Decoder.unsigned(bytes.slice(offset, offset + count)); + } + + function readFromOffset(bytes, offset, length) { + return read(bytes, offset, length); + } + + return { read, readFromOffset }; +} diff --git a/src/schema.js b/src/schema.js deleted file mode 100644 index 490958f..0000000 --- a/src/schema.js +++ /dev/null @@ -1,134 +0,0 @@ -/** Schema parsing component */ - -/* Requires ------------------------------------------------------------------*/ - -const Encoder = require('./encoder'); -const Decoder = require('./decoder'); -const Reader = require('./reader'); -const Writer = require('./writer'); -const Converter = require('./converter'); - -/* Methods -------------------------------------------------------------------*/ - -/** - * Creates a new schema definition, with a reader and writer attached - * @param {*} schema The schema to use - * @param {Object (keyOrder: {boolean})} options The options for the schema - */ -function Schema(schema, options = { keyOrder: false }) { - const sizeRef = { - boolean: 1, - number: 8, - int8: 1, - int16: 2, - int32: 4, - double: 8, - string: 2, - char8: 1, - char16: 2, - char32: 4, - array: 2, - object: 1, - unsigned: 8, - unsigned8: 1, - unsigned16: 2, - unsigned32: 4, - }; - - const defaultSizes = { - boolean: 1, - number: 8, - int8: 1, - int16: 2, - int32: 4, - double: 8, - unsigned: 8, - unsigned8: 1, - unsigned16: 2, - unsigned32: 4, - }; - - const scope = { - schema, - indices: {}, - items: Object.keys(schema), - headerBytes: [0], - contentBytes: [0], - header: [], - contentBegins: 0, - options, - }; - scope.indices = preformat(schema); - const writer = Writer(scope); - const reader = Reader(scope); - - applyBlank(); // Pre-load header for easy streaming - - /** @private */ - function preformat(schema) { - const ret = {}; - Object.keys(schema) - .sort() - .forEach((key, index) => { - const keyType = schema[key].type; - const count = schema[key].count || 1; - const childSchema = computeNested(schema, key, keyType); - - ret[key] = { - name: key, - index, - type: keyType, - transformIn: (childSchema !== undefined) ? Encoder[keyType].bind(null, childSchema) : Encoder[keyType], - transformOut: (childSchema !== undefined) ? Decoder[keyType].bind(null, childSchema) : Decoder[keyType], - coerse: Converter[keyType], - getSize: Encoder.getSize.bind(null, count), - fixedSize: defaultSizes[keyType] && Encoder.getSize(count, defaultSizes[keyType]) || null, - size: schema[key].size || defaultSizes[keyType] || null, - count, - nested: childSchema, - }; - }); - - return ret; - } - - /** @private */ - function applyBlank() { - for (let key in scope.schema) { - scope.header.push({ - key: scope.indices[key], - size: scope.indices[key].size || sizeRef[scope.indices[key].type], - }); - } - } - - /** @private */ - function computeNested(schema, key) { - const keyType = schema[key].type; - const isObject = (keyType === 'object'); - const isArray = (keyType === 'array'); - let childSchema; - - if (isObject === true || isArray === true) { - if (isObject === true) childSchema = Schema(schema[key].schema, options); - if (isArray === true) { - const itemChildSchema = computeNested(schema[key], 'items'); - - childSchema = { - count: schema[key].items.count || 1, - getSize: Encoder.getSize.bind(null, schema[key].items.count || 1), - transformIn: (itemChildSchema !== undefined) ? Encoder[schema[key].items.type].bind(null, itemChildSchema) : Encoder[schema[key].items.type], - transformOut: (itemChildSchema !== undefined) ? Decoder[schema[key].items.type].bind(null, itemChildSchema) : Decoder[schema[key].items.type], - }; - } - } - - return childSchema; - } - - return Object.assign({}, writer, reader); -} - -/* Exports -------------------------------------------------------------------*/ - -module.exports = Schema; \ No newline at end of file diff --git a/src/schema.ts b/src/schema.ts new file mode 100644 index 0000000..450db6b --- /dev/null +++ b/src/schema.ts @@ -0,0 +1,319 @@ +import Encoder from './encoder'; +import Decoder from './decoder'; +import Reader from './reader'; +import Writer from './writer'; +import Converter from './converter'; + +function resolveType(type, format) { + if (type === 'integer') { + const fmt = format || 'int32'; + return fmt === 'int64' ? 'int64' : 'int32'; + } + + if (type === 'number') { + const fmt = format || 'double'; + return fmt === 'float' ? 'float' : 'double'; + } + + if (type === 'string') { + if (format === 'uuid') return 'uuid'; + if (format === 'ipv4') return 'ipv4'; + if (format === 'ipv6') return 'ipv6'; + if (format === 'date') return 'date'; + if (format === 'date-time') return 'date-time'; + if (format === 'binary') return 'binary'; + } + + return type; +} + +export default function Schema(schema, options = {}) { + let unwrappedSchema = schema; + if (schema.type === 'object' && schema.properties) { + unwrappedSchema = schema.properties; + } + + const normalizedSchema = normalizeSchema(unwrappedSchema, options); + + const defaultSizes = { + 'boolean': 1, + 'int32': 4, + 'int64': 8, + 'float': 4, + 'double': 8, + 'uuid': 16, + 'ipv4': 4, + 'ipv6': 16, + 'date': 4, + 'date-time': 9, + }; + + const scope = { + schema: normalizedSchema, + indices: {}, + items: Object.keys(normalizedSchema), + buffer: [], + options, + indexToField: {}, + itemsSet: null, + }; + scope.indices = preformat(normalizedSchema); + + for (const fieldName of scope.items) { + scope.indexToField[scope.indices[fieldName].index] = scope.indices[fieldName]; + } + + scope.itemsSet = new Set(scope.items); + + function resolveRef(ref, options) { + if (!ref || !ref.startsWith('#/')) { + throw new Error(`Invalid $ref format: ${ref}. Only internal references (#/...) are supported.`); + } + + const parts = ref.split('/').slice(1); + let resolved = options.schemas; + + for (const part of parts) { + if (!resolved || typeof resolved !== 'object') { + throw new Error(`Cannot resolve $ref: ${ref}`); + } + resolved = resolved[part]; + } + + if (!resolved) { + throw new Error(`$ref not found: ${ref}`); + } + + return resolved; + } + + function normalizeFieldDefinition(fieldDef, options) { + if (fieldDef.$ref) { + if (!options.schemas) { + throw new Error(`$ref "${fieldDef.$ref}" found but no schemas provided in options`); + } + let resolved = resolveRef(fieldDef.$ref, options); + + if (resolved.type === 'object' && resolved.properties && !resolved.schema) { + resolved = { ...resolved }; + resolved.schema = resolved.properties; + delete resolved.properties; + } + + return normalizeFieldDefinition(resolved, options); + } + + const normalized = { ...fieldDef }; + + if (normalized.properties && !normalized.schema) { + normalized.schema = normalizeSchema(normalized.properties, options); + delete normalized.properties; + } + + if (normalized.items) { + normalized.items = normalizeFieldDefinition(normalized.items, options); + } + + if (normalized.oneOf) { + normalized.oneOf = normalized.oneOf.map(v => normalizeFieldDefinition(v, options)); + } + if (normalized.anyOf) { + normalized.anyOf = normalized.anyOf.map(v => normalizeFieldDefinition(v, options)); + } + + if (normalized.schema && typeof normalized.schema === 'object') { + normalized.schema = normalizeSchema(normalized.schema, options); + } + + return normalized; + } + + function normalizeSchema(schema, options) { + const normalized = {}; + for (const key in schema) { + normalized[key] = normalizeFieldDefinition(schema[key], options); + } + return normalized; + } + const writer = Writer(scope); + const reader = Reader(scope); + + function preformat(schema) { + const ret = {}; + Object.keys(schema) + .sort() + .forEach((key, index) => { + if (schema[key].oneOf || schema[key].anyOf) { + const variantDefs = schema[key].oneOf || schema[key].anyOf; + const variants = variantDefs.map((variantDef) => { + const variantType = variantDef.type; + const variantFormat = variantDef.format; + const variantInternalType = resolveType(variantType, variantFormat); + const variantCount = variantDef.count || (variantInternalType === 'binary' ? 4 : (variantInternalType === 'array' || variantInternalType === 'object' ? 2 : 1)); + const variantChildSchema = computeNestedVariant(variantDef); + + const schemaKeys = (variantInternalType === 'object' && variantDef.schema) + ? Object.keys(variantDef.schema) + : null; + + return { + type: variantInternalType, + transformIn: (variantChildSchema !== undefined) + ? Encoder[variantInternalType].bind(null, variantChildSchema) + : Encoder[variantInternalType], + transformOut: (variantChildSchema !== undefined) + ? Decoder[variantInternalType].bind(null, variantChildSchema) + : Decoder[variantInternalType], + coerse: Converter[variantInternalType], + getSize: Encoder.getSize.bind(null, variantCount), + fixedSize: (defaultSizes[variantInternalType] && Encoder.getSize(variantCount, defaultSizes[variantInternalType])) || null, + size: defaultSizes[variantInternalType] || null, + count: variantCount, + nested: variantChildSchema, + schemaKeys, + }; + }); + + ret[key] = { + name: key, + index, + nullable: schema[key].nullable || false, + variants, + }; + return; + } + + const fieldType = schema[key].type; + const fieldFormat = schema[key].format; + const internalType = resolveType(fieldType, fieldFormat); + const count = schema[key].count || (internalType === 'binary' ? 4 : (internalType === 'array' || internalType === 'object' ? 2 : 1)); + const childSchema = computeNested(schema, key); + + ret[key] = { + name: key, + index, + type: internalType, + nullable: schema[key].nullable || false, + transformIn: (childSchema !== undefined) ? Encoder[internalType].bind(null, childSchema) : Encoder[internalType], + transformOut: (childSchema !== undefined) ? Decoder[internalType].bind(null, childSchema) : Decoder[internalType], + coerse: Converter[internalType], + getSize: Encoder.getSize.bind(null, count), + fixedSize: (defaultSizes[internalType] && Encoder.getSize(count, defaultSizes[internalType])) || null, + size: defaultSizes[internalType] || null, + count, + nested: childSchema, + }; + }); + + return ret; + } + + function computeNested(schema, key) { + const keyType = schema[key].type; + const isObject = (keyType === 'object'); + const isArray = (keyType === 'array'); + let childSchema; + + if (isObject === true || isArray === true) { + if (isObject === true) { + childSchema = Schema(schema[key].schema, options); + } + if (isArray === true) { + childSchema = processArrayItems(schema[key].items); + } + } + + return childSchema; + } + + function processArrayItems(itemDef) { + if (itemDef.oneOf || itemDef.anyOf) { + const variantDefs = itemDef.oneOf || itemDef.anyOf; + const variants = variantDefs.map((variantDef) => { + const variantType = variantDef.type; + const variantFormat = variantDef.format; + const variantInternalType = resolveType(variantType, variantFormat); + const variantCount = variantDef.count || (variantInternalType === 'binary' ? 4 : (variantInternalType === 'array' || variantInternalType === 'object' ? 2 : 1)); + const variantChildSchema = processArrayItemsNested(variantDef); + + const schemaKeys = (variantInternalType === 'object' && variantDef.schema) + ? Object.keys(variantDef.schema) + : null; + + return { + type: variantInternalType, + transformIn: (variantChildSchema !== undefined) + ? Encoder[variantInternalType].bind(null, variantChildSchema) + : Encoder[variantInternalType], + transformOut: (variantChildSchema !== undefined) + ? Decoder[variantInternalType].bind(null, variantChildSchema) + : Decoder[variantInternalType], + coerse: Converter[variantInternalType], + getSize: Encoder.getSize.bind(null, variantCount), + fixedSize: (defaultSizes[variantInternalType] && Encoder.getSize(variantCount, defaultSizes[variantInternalType])) || null, + size: defaultSizes[variantInternalType] || null, + count: variantCount, + nested: variantChildSchema, + schemaKeys, + }; + }); + + return { + nullable: itemDef.nullable || false, + variants, + count: 1, + getSize: Encoder.getSize.bind(null, 1), + }; + } + + const itemType = itemDef.type; + const itemFormat = itemDef.format; + const internalItemType = resolveType(itemType, itemFormat); + const itemCount = itemDef.count || (internalItemType === 'binary' ? 4 : (internalItemType === 'array' || internalItemType === 'object' ? 2 : 1)); + const itemChildSchema = processArrayItemsNested(itemDef); + + return { + nullable: itemDef.nullable || false, + count: itemCount, + getSize: Encoder.getSize.bind(null, itemCount), + transformIn: (itemChildSchema !== undefined) ? Encoder[internalItemType].bind(null, itemChildSchema) : Encoder[internalItemType], + transformOut: (itemChildSchema !== undefined) ? Decoder[internalItemType].bind(null, itemChildSchema) : Decoder[internalItemType], + }; + } + + function processArrayItemsNested(itemDef) { + const itemType = itemDef.type; + const isObject = (itemType === 'object'); + const isArray = (itemType === 'array'); + + if (isObject === true) { + return Schema(itemDef.schema, options); + } + + if (isArray === true) { + return processArrayItems(itemDef.items); + } + + return undefined; + } + + function computeNestedVariant(variantDef) { + const variantType = variantDef.type; + const isObject = (variantType === 'object'); + const isArray = (variantType === 'array'); + let childSchema; + + if (isObject === true || isArray === true) { + if (isObject === true) { + childSchema = Schema(variantDef.schema, options); + } + if (isArray === true) { + childSchema = processArrayItems(variantDef.items); + } + } + + return childSchema; + } + + return Object.assign({}, writer, reader); +} diff --git a/src/variant-matcher.ts b/src/variant-matcher.ts new file mode 100644 index 0000000..809f763 --- /dev/null +++ b/src/variant-matcher.ts @@ -0,0 +1,45 @@ +export const TYPE_COMPAT_MAP = { + number: new Set(['int32', 'int64', 'float', 'double']), + string: new Set(['string', 'uuid', 'ipv4', 'ipv6', 'date', 'date-time', 'binary']), + boolean: new Set(['boolean']), + array: new Set(['array']), + object: new Set(['object']), +}; + +export function matchesVariant(data, variant) { + let dataType; + if (data === null) { + dataType = 'null'; + } + else if (Array.isArray(data)) { + dataType = 'array'; + } + else { + dataType = typeof data; + } + + if (variant.type === 'binary' && (data instanceof Buffer || data instanceof Uint8Array)) { + return true; + } + + const compatibleTypes = TYPE_COMPAT_MAP[dataType]; + if (compatibleTypes && compatibleTypes.has(variant.type)) { + if (variant.type === 'object' && variant.schemaKeys && variant.schemaKeys.length > 0) { + const schemaKeys = variant.schemaKeys; + const dataKeys = Object.keys(data); + + let matchCount = 0; + for (const key of schemaKeys) { + if (data.hasOwnProperty(key)) { + matchCount++; + } + } + + return matchCount > 0 && matchCount >= Math.min(schemaKeys.length, dataKeys.length) * 0.5; + } + + return true; + } + + return false; +} diff --git a/src/writer.js b/src/writer.js deleted file mode 100644 index 80c429c..0000000 --- a/src/writer.js +++ /dev/null @@ -1,110 +0,0 @@ -/** Data writer component */ - -/* Methods -------------------------------------------------------------------*/ - -function Writer(scope) { - - /** - * Start writing some data against a schema - * @param {*} data The data to be encoded - * @param {Object (coerce: {boolean}, validate: {boolean})} options The options for the encoding - * @returns {Writer} Self reference - */ - function write(data, options) { - scope.headerBytes = [0]; - scope.contentBytes = []; - - const keys = filterKeys(data); - scope.headerBytes[0] = keys.length; - for (let i = 0; i < keys.length; i++) { - let keyData = data[keys[i]]; - if (options !== undefined) { - if (options.coerse === true) keyData = scope.indices[keys[i]].coerse(keyData); - if (options.validate === true) scope.indices[keys[i]].validate(keyData); - } - splitBytes(scope.indices[keys[i]].transformIn(keyData), keys[i]); - } - - return this; - } - - /** @private */ - function splitBytes(encoded, key) { - if (scope.indices[key].fixedSize !== null) { - scope.headerBytes.push(scope.indices[key].index, ...scope.indices[key].fixedSize); - } - else { - scope.headerBytes.push(scope.indices[key].index, ...scope.indices[key].getSize(encoded.length)); - if(scope.indices[key].size !== encoded.length && scope.indices[key].size !== null) { - let fixedSize = new Array(scope.indices[key].size).fill(0); - let smallestSize = Math.min(encoded.length, fixedSize.length); - fixedSize.splice(0, smallestSize, ...encoded.slice(0, smallestSize)); - return scope.contentBytes.push(...fixedSize); - } - } - scope.contentBytes.push(...encoded); - } - - /** - * Returns the byte sizes of a data object, for insight or troubleshooting - * @param {*} data The data to extract size information of - * @returns {Object} The detailed sizes information - */ - function sizes(data) { - const s = {}; - for (let key in data) { - if (data[key] instanceof Object) { - s[key] = scope.indices[key].nested.sizes(data[key]); - s.size = scope.indices[key].transformIn(data[key]).length; - } - else s[key] = scope.indices[key].transformIn(data[key]).length; - } - - return s; - } - - /** @private */ - function filterKeys(data) { - const res = []; - for (let key in data) { - if (scope.items.indexOf(key) !== -1 && data[key] !== null && data[key] !== undefined) res.push(key); - } - return res; - } - - /** @private */ - function typedArray() { - return [...scope.headerBytes, ...scope.contentBytes]; - } - - /** - * Returns the bytes from the header of the encoded data buffer. - * A fresh schema with no written data will return a blank, usable for partial encodings. - * @returns {Buffer} The header buffer - */ - function headerBuffer() { - return Buffer.from(scope.headerBytes); - } - - /** - * Returns the bytes from the content of the encoded data buffer. - * @returns {Buffer} The content buffer - */ - function contentBuffer() { - return Buffer.from(scope.contentBytes); - } - - /** - * Returns the bytes from the header AND content of the encoded data buffer. - * @returns {Buffer} The data buffer - */ - function buffer() { - return Buffer.from(typedArray()); - } - - return { write, headerBuffer, contentBuffer, buffer, typedArray, sizes }; -} - -/* Exports -------------------------------------------------------------------*/ - -module.exports = Writer; \ No newline at end of file diff --git a/src/writer.ts b/src/writer.ts new file mode 100644 index 0000000..fdac965 --- /dev/null +++ b/src/writer.ts @@ -0,0 +1,185 @@ +import { NULL_INDICATOR, VARIANT_BASE } from './encoder'; +import { writeFieldWithSize, processVariantWrite } from './buffer-utils'; +import { log } from './logger'; + +export default function Writer(scope) { + function estimateBufferSize(keys) { + let size = 1; + + for (let i = 0; i < keys.length; i++) { + const field = scope.indices[keys[i]]; + + size += field.nullable || field.variants ? 2 : 1; + + if (field.fixedSize) { + size += field.fixedSize.length; + } + else { + size += field.count || 1; + } + + if (field.size) { + size += field.size; + } + else { + if (field.type === 'string') size += 32; + else if (field.type === 'array') size += 64; + else if (field.type === 'object') size += 128; + else size += 16; + } + } + + return Math.max(Math.floor(size * 1.5), 256); + } + + function ensureCapacity(needed) { + if (scope.position + needed > scope.buffer.length) { + const newSize = Math.max(scope.buffer.length * 2, scope.position + needed); + const newBuffer = Buffer.allocUnsafe(newSize); + scope.buffer.copy(newBuffer, 0, 0, scope.position); + scope.buffer = newBuffer; + } + } + + function write(data, options?) { + const keys = filterKeys(data, options); + + const estimatedSize = estimateBufferSize(keys); + scope.buffer = Buffer.allocUnsafe(estimatedSize); + scope.position = 0; + + scope.buffer[scope.position++] = keys.length; + + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + let keyData = data[key]; + const field = scope.indices[key]; + + if (field.nullable && keyData === null) { + ensureCapacity(2); + scope.buffer[scope.position++] = field.index; + scope.buffer[scope.position++] = NULL_INDICATOR; + continue; + } + + if (field.variants) { + if (options !== undefined) { + for (let v = 0; v < field.variants.length; v++) { + const variant = field.variants[v]; + if (options.coerse === true && variant.coerse) { + keyData = variant.coerse(keyData); + } + if (options.validate === true && variant.validate) { + variant.validate(keyData); + } + } + } + + ensureCapacity(2); + scope.buffer[scope.position++] = field.index; + scope.position = processVariantWrite(scope.buffer, scope.position, keyData, field, `Field: ${keys[i]}`); + continue; + } + + if (field.nullable) { + ensureCapacity(2); + scope.buffer[scope.position++] = field.index; + scope.buffer[scope.position++] = VARIANT_BASE; + } + else { + ensureCapacity(1); + scope.buffer[scope.position++] = field.index; + } + + if (options !== undefined) { + if (options.coerse === true && field.coerse) keyData = field.coerse(keyData); + if (options.validate === true && field.validate) field.validate(keyData); + } + + scope.position = writeFieldValue(keyData, field); + } + + return Buffer.from(scope.buffer.subarray(0, scope.position)); + } + + function writeFieldValue(value, fieldOrVariant) { + ensureCapacity(1024); + return writeFieldWithSize(scope.buffer, scope.position, value, fieldOrVariant); + } + + function sizes(data) { + const s: any = {}; + for (const key in data) { + if (data[key] instanceof Object) { + s[key] = scope.indices[key].nested.sizes(data[key]); + s.size = scope.indices[key].transformIn(data[key]).length; + } + else s[key] = scope.indices[key].transformIn(data[key]).length; + } + + return s; + } + + function filterKeys(data) { + const res = []; + const undeclaredKeys = []; + + for (const key in data) { + if (!scope.itemsSet.has(key)) { + undeclaredKeys.push(key); + continue; + } + + if (scope.indices[key].nullable && data[key] === null) { + res.push(key); + continue; + } + + if (data[key] !== null && data[key] !== undefined) { + res.push(key); + } + } + + if (undeclaredKeys.length > 0) { + log( + `Schema validation warning: Object contains undeclared properties that will not be serialized: ${undeclaredKeys.join(', ')}`, + ); + } + + return res; + } + + function writeToBuffer(data, buffer, pos) { + const keys = filterKeys(data); + + buffer[pos++] = keys.length; + + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + const keyData = data[key]; + const field = scope.indices[key]; + + buffer[pos++] = field.index; + + if (field.nullable && keyData === null) { + buffer[pos++] = NULL_INDICATOR; + continue; + } + + if (field.variants) { + pos = processVariantWrite(buffer, pos, keyData, field, `Field: ${key}`); + continue; + } + + if (field.nullable) { + buffer[pos++] = VARIANT_BASE; + } + + pos = writeFieldWithSize(buffer, pos, keyData, field); + } + + return pos; + } + + return { write, sizes, writeToBuffer }; +} diff --git a/static/exampleSpec.ts b/static/exampleSpec.ts new file mode 100644 index 0000000..62692eb --- /dev/null +++ b/static/exampleSpec.ts @@ -0,0 +1,614 @@ +// Pulling an OpenAPI spec from https://apis.guru/ + +export default +{ + openapi: '3.0.0', + servers: [ + { + url: 'https://www.googleapis.com/siteVerification/v1', + }, + ], + info: { + 'contact': { + 'name': 'Google', + 'url': 'https://google.com', + 'x-twitter': 'youtube', + }, + 'description': 'Verifies ownership of websites or domains with Google.', + 'license': { + name: 'Creative Commons Attribution 3.0', + url: 'http://creativecommons.org/licenses/by/3.0/', + }, + 'termsOfService': 'https://developers.google.com/terms/', + 'title': 'Google Site Verification API', + 'version': 'v1', + 'x-apiClientRegistration': { + url: 'https://console.developers.google.com', + }, + 'x-apisguru-categories': [ + 'analytics', + 'media', + ], + 'x-logo': { + url: 'https://api.apis.guru/v2/cache/logo/https_www.google.com_images_branding_googlelogo_2x_googlelogo_color_272x92dp.png', + }, + 'x-origin': [ + { + format: 'google', + url: 'https://siteverification.googleapis.com/$discovery/rest?version=v1', + version: 'v1', + }, + ], + 'x-providerName': 'googleapis.com', + 'x-serviceName': 'siteVerification', + }, + externalDocs: { + url: 'https://developers.google.com/site-verification/', + }, + tags: [ + { + name: 'webResource', + }, + ], + paths: { + '/token': { + parameters: [ + { + $ref: '#/components/parameters/alt', + }, + { + $ref: '#/components/parameters/fields', + }, + { + $ref: '#/components/parameters/key', + }, + { + $ref: '#/components/parameters/oauth_token', + }, + { + $ref: '#/components/parameters/prettyPrint', + }, + { + $ref: '#/components/parameters/quotaUser', + }, + { + $ref: '#/components/parameters/userIp', + }, + ], + post: { + description: 'Get a verification token for placing on a website or domain.', + operationId: 'siteVerification.webResource.getToken', + requestBody: { + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/SiteVerificationWebResourceGettokenRequest', + }, + }, + }, + }, + responses: { + 200: { + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/SiteVerificationWebResourceGettokenResponse', + }, + }, + }, + description: 'Successful response', + }, + }, + security: [ + { + Oauth2: [ + 'https://www.googleapis.com/auth/siteverification', + ], + Oauth2c: [ + 'https://www.googleapis.com/auth/siteverification', + ], + }, + { + Oauth2: [ + 'https://www.googleapis.com/auth/siteverification.verify_only', + ], + Oauth2c: [ + 'https://www.googleapis.com/auth/siteverification.verify_only', + ], + }, + ], + tags: [ + 'webResource', + ], + }, + }, + '/webResource': { + get: { + description: 'Get the list of your verified websites and domains.', + operationId: 'siteVerification.webResource.list', + responses: { + 200: { + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/SiteVerificationWebResourceListResponse', + }, + }, + }, + description: 'Successful response', + }, + }, + security: [ + { + Oauth2: [ + 'https://www.googleapis.com/auth/siteverification', + ], + Oauth2c: [ + 'https://www.googleapis.com/auth/siteverification', + ], + }, + ], + tags: [ + 'webResource', + ], + }, + parameters: [ + { + $ref: '#/components/parameters/alt', + }, + { + $ref: '#/components/parameters/fields', + }, + { + $ref: '#/components/parameters/key', + }, + { + $ref: '#/components/parameters/oauth_token', + }, + { + $ref: '#/components/parameters/prettyPrint', + }, + { + $ref: '#/components/parameters/quotaUser', + }, + { + $ref: '#/components/parameters/userIp', + }, + ], + post: { + description: 'Attempt verification of a website or domain.', + operationId: 'siteVerification.webResource.insert', + parameters: [ + { + description: 'The method to use for verifying a site or domain.', + in: 'query', + name: 'verificationMethod', + required: true, + schema: { + type: 'string', + }, + }, + ], + requestBody: { + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/SiteVerificationWebResourceResource', + }, + }, + }, + }, + responses: { + 200: { + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/SiteVerificationWebResourceResource', + }, + }, + }, + description: 'Successful response', + }, + }, + security: [ + { + Oauth2: [ + 'https://www.googleapis.com/auth/siteverification', + ], + Oauth2c: [ + 'https://www.googleapis.com/auth/siteverification', + ], + }, + { + Oauth2: [ + 'https://www.googleapis.com/auth/siteverification.verify_only', + ], + Oauth2c: [ + 'https://www.googleapis.com/auth/siteverification.verify_only', + ], + }, + ], + tags: [ + 'webResource', + ], + }, + }, + '/webResource/{id}': { + delete: { + description: 'Relinquish ownership of a website or domain.', + operationId: 'siteVerification.webResource.delete', + parameters: [ + { + description: 'The id of a verified site or domain.', + in: 'path', + name: 'id', + required: true, + schema: { + type: 'string', + }, + }, + ], + responses: { + 200: { + description: 'Successful response', + }, + }, + security: [ + { + Oauth2: [ + 'https://www.googleapis.com/auth/siteverification', + ], + Oauth2c: [ + 'https://www.googleapis.com/auth/siteverification', + ], + }, + ], + tags: [ + 'webResource', + ], + }, + get: { + description: 'Get the most current data for a website or domain.', + operationId: 'siteVerification.webResource.get', + parameters: [ + { + description: 'The id of a verified site or domain.', + in: 'path', + name: 'id', + required: true, + schema: { + type: 'string', + }, + }, + ], + responses: { + 200: { + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/SiteVerificationWebResourceResource', + }, + }, + }, + description: 'Successful response', + }, + }, + security: [ + { + Oauth2: [ + 'https://www.googleapis.com/auth/siteverification', + ], + Oauth2c: [ + 'https://www.googleapis.com/auth/siteverification', + ], + }, + ], + tags: [ + 'webResource', + ], + }, + parameters: [ + { + $ref: '#/components/parameters/alt', + }, + { + $ref: '#/components/parameters/fields', + }, + { + $ref: '#/components/parameters/key', + }, + { + $ref: '#/components/parameters/oauth_token', + }, + { + $ref: '#/components/parameters/prettyPrint', + }, + { + $ref: '#/components/parameters/quotaUser', + }, + { + $ref: '#/components/parameters/userIp', + }, + ], + patch: { + description: 'Modify the list of owners for your website or domain. This method supports patch semantics.', + operationId: 'siteVerification.webResource.patch', + parameters: [ + { + description: 'The id of a verified site or domain.', + in: 'path', + name: 'id', + required: true, + schema: { + type: 'string', + }, + }, + ], + requestBody: { + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/SiteVerificationWebResourceResource', + }, + }, + }, + }, + responses: { + 200: { + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/SiteVerificationWebResourceResource', + }, + }, + }, + description: 'Successful response', + }, + }, + security: [ + { + Oauth2: [ + 'https://www.googleapis.com/auth/siteverification', + ], + Oauth2c: [ + 'https://www.googleapis.com/auth/siteverification', + ], + }, + ], + tags: [ + 'webResource', + ], + }, + put: { + description: 'Modify the list of owners for your website or domain.', + operationId: 'siteVerification.webResource.update', + parameters: [ + { + description: 'The id of a verified site or domain.', + in: 'path', + name: 'id', + required: true, + schema: { + type: 'string', + }, + }, + ], + requestBody: { + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/SiteVerificationWebResourceResource', + }, + }, + }, + }, + responses: { + 200: { + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/SiteVerificationWebResourceResource', + }, + }, + }, + description: 'Successful response', + }, + }, + security: [ + { + Oauth2: [ + 'https://www.googleapis.com/auth/siteverification', + ], + Oauth2c: [ + 'https://www.googleapis.com/auth/siteverification', + ], + }, + ], + tags: [ + 'webResource', + ], + }, + }, + }, + components: { + user: { + description: 'User model', + type: 'object', + properties: { + id: { type: 'string', required: true }, + name: { type: 'string' }, + age: { type: 'integer' }, + }, + }, + parameters: { + alt: { + description: 'Data format for the response.', + in: 'query', + name: 'alt', + schema: { + enum: [ + 'json', + ], + type: 'string', + }, + }, + fields: { + description: 'Selector specifying which fields to include in a partial response.', + in: 'query', + name: 'fields', + schema: { + type: 'string', + }, + }, + key: { + description: 'API key. Your API key identifies your project and provides you with API access, quota, and reports. Required unless you provide an OAuth 2.0 token.', + in: 'query', + name: 'key', + schema: { + type: 'string', + }, + }, + oauth_token: { + description: 'OAuth 2.0 token for the current user.', + in: 'query', + name: 'oauth_token', + schema: { + type: 'string', + }, + }, + prettyPrint: { + description: 'Returns response with indentations and line breaks.', + in: 'query', + name: 'prettyPrint', + schema: { + type: 'boolean', + }, + }, + quotaUser: { + description: 'An opaque string that represents a user for quota purposes. Must not exceed 40 characters.', + in: 'query', + name: 'quotaUser', + schema: { + type: 'string', + }, + }, + userIp: { + description: 'Deprecated. Please use quotaUser instead.', + in: 'query', + name: 'userIp', + schema: { + type: 'string', + }, + }, + }, + schemas: { + SiteVerificationWebResourceGettokenRequest: { + properties: { + site: { + description: 'The site for which a verification token will be generated.', + properties: { + identifier: { + description: 'The site identifier. If the type is set to SITE, the identifier is a URL. If the type is set to INET_DOMAIN, the site identifier is a domain name.', + type: 'string', + }, + type: { + description: 'The type of resource to be verified. Can be SITE or INET_DOMAIN (domain name).', + type: 'string', + }, + }, + type: 'object', + }, + verificationMethod: { + description: 'The verification method that will be used to verify this site. For sites, \'FILE\' or \'META\' methods may be used. For domains, only \'DNS\' may be used.', + type: 'string', + }, + }, + type: 'object', + }, + SiteVerificationWebResourceGettokenResponse: { + properties: { + method: { + description: 'The verification method to use in conjunction with this token. For FILE, the token should be placed in the top-level directory of the site, stored inside a file of the same name. For META, the token should be placed in the HEAD tag of the default page that is loaded for the site. For DNS, the token should be placed in a TXT record of the domain.', + type: 'string', + }, + token: { + description: 'The verification token. The token must be placed appropriately in order for verification to succeed.', + type: 'string', + }, + }, + type: 'object', + }, + SiteVerificationWebResourceListResponse: { + properties: { + items: { + description: 'The list of sites that are owned by the authenticated user.', + items: { + $ref: '#/components/schemas/SiteVerificationWebResourceResource', + }, + type: 'array', + }, + }, + type: 'object', + }, + SiteVerificationWebResourceResource: { + properties: { + id: { + description: 'The string used to identify this site. This value should be used in the "id" portion of the REST URL for the Get, Update, and Delete operations.', + type: 'string', + }, + owners: { + description: 'The email addresses of all verified owners.', + items: { + type: 'string', + }, + type: 'array', + }, + site: { + description: 'The address and type of a site that is verified or will be verified.', + properties: { + identifier: { + description: 'The site identifier. If the type is set to SITE, the identifier is a URL. If the type is set to INET_DOMAIN, the site identifier is a domain name.', + type: 'string', + }, + type: { + description: 'The site type. Can be SITE or INET_DOMAIN (domain name).', + type: 'string', + }, + }, + type: 'object', + }, + }, + type: 'object', + }, + }, + securitySchemes: { + Oauth2: { + description: 'Oauth 2.0 implicit authentication', + flows: { + implicit: { + authorizationUrl: 'https://accounts.google.com/o/oauth2/auth', + scopes: { + 'https://www.googleapis.com/auth/siteverification': 'Manage the list of sites and domains you control', + 'https://www.googleapis.com/auth/siteverification.verify_only': 'Manage your new site verifications with Google', + }, + }, + }, + type: 'oauth2', + }, + Oauth2c: { + description: 'Oauth 2.0 authorizationCode authentication', + flows: { + authorizationCode: { + authorizationUrl: 'https://accounts.google.com/o/oauth2/auth', + scopes: { + 'https://www.googleapis.com/auth/siteverification': 'Manage the list of sites and domains you control', + 'https://www.googleapis.com/auth/siteverification.verify_only': 'Manage your new site verifications with Google', + }, + tokenUrl: 'https://accounts.google.com/o/oauth2/token', + }, + }, + type: 'oauth2', + }, + }, + }, +}; diff --git a/tests/index.js b/tests/index.js deleted file mode 100644 index 354b3a0..0000000 --- a/tests/index.js +++ /dev/null @@ -1,244 +0,0 @@ -/** - * Unit test suite - */ - -/* Requires ------------------------------------------------------------------*/ - -const expect = require('chai').expect; -const Compactr = require('../'); - -/* Tests ---------------------------------------------------------------------*/ - -describe('Data integrity - simple', () => { - describe('Boolean', () => { - const Schema = Compactr.schema({ test: { type: 'boolean' } }); - - it('should preserve boolean value and type - true', () => { - expect(Schema.read(Schema.write({ test: true }).buffer())).to.deep.equal({ test: true }); - }); - - it('should preserve boolean value and type - false', () => { - expect(Schema.read(Schema.write({ test: false }).buffer())).to.deep.equal({ test: false }); - }); - - it('should skip null or undefined values', () => { - expect(Schema.read(Schema.write({ test: null }).buffer())).to.deep.equal({}); - }); - }); - - describe('Number', () => { - const Schema = Compactr.schema({ test: { type: 'number' } }); - - it('should preserve number value and type', () => { - expect(Schema.read(Schema.write({ test: 23.23 }).buffer())).to.deep.equal({ test: 23.23 }); - }); - - it('should preserve number value and type for negative values', () => { - expect(Schema.read(Schema.write({ test: -23.23 }).buffer())).to.deep.equal({ test: -23.23 }); - }); - }); - - describe('String', () => { - const Schema = Compactr.schema({ test: { type: 'string' } }); - - it('should preserve string value and type', () => { - expect(Schema.read(Schema.write({ test: 'hello world' }).buffer())).to.deep.equal({ test: 'hello world' }); - }); - }); - - describe('Array', () => { - const Schema = Compactr.schema({ test: { type: 'array', items: { type: 'string' } } }); - - it('should preserve array values and types', () => { - expect(Schema.read(Schema.write({ test: ['a', 'b', 'c'] }).buffer())).to.deep.equal({ test: ['a', 'b', 'c'] }); - }); - }); - - describe('Schema', () => { - const Schema = Compactr.schema({ test: { type: 'object', schema: { test: { type: 'number' } } } }); - - it('should preserve object values and types', () => { - expect(Schema.read(Schema.write({ test: { test: 23.23 } }).buffer())).to.deep.equal({ test: { test: 23.23 } }); - }); - }); -}); - -describe('Data integrity - multi simple', () => { - describe('Booleans', () => { - const Schema = Compactr.schema({ test: { type: 'boolean' }, test2: { type: 'boolean' } }); - - it('should preserve boolean value and type - false', () => { - expect(Schema.read(Schema.write({ test: false, test2: true }).buffer())).to.deep.equal({ test: false, test2: true }); - }); - - it('should skip null or undefined values', () => { - expect(Schema.read(Schema.write({ test: null, test2: false }).buffer())).to.deep.equal({ test2: false }); - }); - }); - - describe('Numbers', () => { - const Schema = Compactr.schema({ test: { type: 'number' }, test2: { type: 'number' } }); - - it('should preserve number value and type', () => { - expect(Schema.read(Schema.write({ test: 23.23, test2: -97.7 }).buffer())).to.deep.equal({ test: 23.23, test2: -97.7 }); - }); - }); - - describe('Strings', () => { - const Schema = Compactr.schema({ test: { type: 'string' }, test2: { type: 'string' } }); - - it('should preserve string value and type', () => { - expect(Schema.read(Schema.write({ test: 'hello world', test2: 'écho' }).buffer())).to.deep.equal({ test: 'hello world', test2: 'écho' }); - }); - }); - - describe('Arrays', () => { - const Schema = Compactr.schema({ test: { type: 'array', items: { type: 'string' } }, test2: { type: 'array', items: { type: 'string' } } }); - - it('should preserve array values and types', () => { - expect(Schema.read(Schema.write({ test: ['a', 'b', 'c'], test2: ['d', 'e', 'f'] }).buffer())).to.deep.equal({ test: ['a', 'b', 'c'], test2: ['d', 'e', 'f'] }); - }); - }); - - describe('Schemas', () => { - const Schema = Compactr.schema({ test: { type: 'object', schema: { test: { type: 'number' } } }, test2: { type: 'object', schema: { test: { type: 'number' } } } }); - - it('should preserve object values and types', () => { - expect(Schema.read(Schema.write({ test: { test: 23.23 }, test2: { test: -97.7 } }).buffer())).to.deep.equal({ test: { test: 23.23 }, test2: { test: -97.7 } }); - }); - }); -}); - -describe('Data integrity - multi mixed', () => { - describe('Boolean + number + string + array + object', () => { - const Schema = Compactr.schema({ - bool: { type: 'boolean' }, - num: { type: 'number' }, - str: { type: 'string' }, - arr: { type: 'array', items: { type: 'string' } }, - obj: { type: 'object', schema: { sub: { type: 'string' } } }, - }); - - it('should preserve values and types', () => { - expect(Schema.read(Schema.write({ bool: true, num: 23.23, str: 'hello world', arr: ['a', 'b', 'c'], obj: { sub: 'way' } }).buffer())).to.deep.equal({ bool: true, num: 23.23, str: 'hello world', arr: ['a', 'b', 'c'], obj: { sub: 'way' } }); - }); - }); -}); - -/* Partial -------------------------------------------------------------------*/ - -describe('Data integrity - partial - simple', () => { - describe('Boolean', () => { - const Schema = Compactr.schema({ test: { type: 'boolean' } }); - - it('should preserve boolean value and type - true', () => { - expect(Schema.readContent(Schema.write({ test: true }).contentBuffer())).to.deep.equal({ test: true }); - }); - - it('should preserve boolean value and type - false', () => { - expect(Schema.readContent(Schema.write({ test: false }).contentBuffer())).to.deep.equal({ test: false }); - }); - - it('should still send one 0 byte in case of null (coersed)', () => { - expect(Schema.readContent(Schema.write({ test: null }).contentBuffer())).to.deep.equal({ test: false }); - }); - }); - - describe('Number', () => { - const Schema = Compactr.schema({ test: { type: 'number' } }); - - it('should preserve number value and type', () => { - expect(Schema.readContent(Schema.write({ test: 23.23 }).contentBuffer())).to.deep.equal({ test: 23.23 }); - }); - - it('should preserve number value and type for negative values', () => { - expect(Schema.readContent(Schema.write({ test: -23.23 }).contentBuffer())).to.deep.equal({ test: -23.23 }); - }); - }); - - describe('String', () => { - const Schema = Compactr.schema({ test: { type: 'string', size: 22 } }); - - it('should preserve string value and type', () => { - expect(Schema.readContent(Schema.write({ test: 'hello world' }).contentBuffer())).to.deep.equal({ test: 'hello world' }); - }); - }); - - describe('Array', () => { - const Schema = Compactr.schema({ test: { type: 'array', size: 12, items: { type: 'string' } } }); - - it('should preserve array values and types', () => { - expect(Schema.readContent(Schema.write({ test: ['a', 'b', 'c'] }).contentBuffer())).to.deep.equal({ test: ['a', 'b', 'c', '', '', ''] }); - }); - }); - - describe('Schema', () => { - const Schema = Compactr.schema({ test: { type: 'object', size: 20, schema: { test: { type: 'number' } } } }); - - it('should preserve object values and types', () => { - expect(Schema.readContent(Schema.write({ test: { test: 23.23 } }).contentBuffer())).to.deep.equal({ test: { test: 23.23 } }); - }); - }); -}); - -describe('Data integrity - partial - multi simple', () => { - describe('Booleans', () => { - const Schema = Compactr.schema({ test: { type: 'boolean' }, test2: { type: 'boolean' } }); - - it('should preserve boolean value and type - false', () => { - expect(Schema.readContent(Schema.write({ test: false, test2: true }).contentBuffer())).to.deep.equal({ test: false, test2: true }); - }); - - it('should skip null or undefined values', () => { - expect(Schema.readContent(Schema.write({ test: null, test2: false }).contentBuffer())).to.deep.equal({ test: false, test2: false }); - }); - }); - - describe('Numbers', () => { - const Schema = Compactr.schema({ test: { type: 'number' }, test2: { type: 'number' } }); - - it('should preserve number value and type', () => { - expect(Schema.readContent(Schema.write({ test: 23.23, test2: -97.7 }).contentBuffer())).to.deep.equal({ test: 23.23, test2: -97.7 }); - }); - }); - - describe('Strings', () => { - const Schema = Compactr.schema({ test: { type: 'string', size: 22 }, test2: { type: 'string', size: 8 } }); - - it('should preserve string value and type', () => { - expect(Schema.readContent(Schema.write({ test: 'hello world', test2: 'écho' }).contentBuffer())).to.deep.equal({ test: 'hello world', test2: 'écho' }); - }); - }); - - describe('Arrays', () => { - const Schema = Compactr.schema({ test: { type: 'array', size: 9, items: { type: 'string' } }, test2: { type: 'array', size: 9, items: { type: 'string' } } }); - - it('should preserve array values and types', () => { - expect(Schema.readContent(Schema.write({ test: ['a', 'b', 'c'], test2: ['d', 'e', 'f'] }).contentBuffer())).to.deep.equal({ test: ['a', 'b', 'c'], test2: ['d', 'e', 'f'] }); - }); - }); - - describe('Schemas', () => { - const Schema = Compactr.schema({ test: { type: 'object', size: 11, schema: { test: { type: 'number' } } }, test2: { type: 'object', size: 11, schema: { test: { type: 'number' } } } }); - - it('should preserve object values and types', () => { - expect(Schema.readContent(Schema.write({ test: { test: 23.23 }, test2: { test: -97.7 } }).contentBuffer())).to.deep.equal({ test: { test: 23.23 }, test2: { test: -97.7 } }); - }); - }); -}); - -describe('Data integrity - partial - multi mixed', () => { - describe('Boolean + number + string + array + object', () => { - const Schema = Compactr.schema({ - bool: { type: 'boolean' }, - num: { type: 'number' }, - str: { type: 'string', size: 22 }, - arr: { type: 'array', items: { type: 'string' }, size: 9 }, - obj: { type: 'object', size: 9, schema: { sub: { type: 'string' } } }, - }); - - it('should preserve values and types', () => { - expect(Schema.readContent(Schema.write({ bool: true, num: 23.23, str: 'hello world', arr: ['a', 'b', 'c'], obj: { sub: 'way' } }).contentBuffer())).to.deep.equal({ bool: true, num: 23.23, str: 'hello world', arr: ['a', 'b', 'c'], obj: { sub: 'way' } }); - }); - }); -}); \ No newline at end of file diff --git a/tests/integration/index.ts b/tests/integration/index.ts new file mode 100644 index 0000000..3426ca7 --- /dev/null +++ b/tests/integration/index.ts @@ -0,0 +1,2011 @@ +import { schema } from '../../src'; +import * as logger from '../../src/logger'; + +/* Tests --------------------------------------------------------------------- */ + +describe('Data integrity - simple', () => { + describe('Boolean', () => { + const Schema = schema({ test: { type: 'boolean' } }); + + it('should preserve boolean value and type - true', () => { + expect(Schema.read(Schema.write({ test: true }))).toEqual({ test: true }); + }); + + it('should preserve boolean value and type - false', () => { + expect(Schema.read(Schema.write({ test: false }))).toEqual({ test: false }); + }); + + it('should skip null or undefined values', () => { + expect(Schema.read(Schema.write({ test: null }))).toEqual({}); + }); + }); + + describe('Number', () => { + const Schema = schema({ test: { type: 'number', format: 'double' } }); + + it('should preserve number value and type', () => { + expect(Schema.read(Schema.write({ test: 23.23 }))).toEqual({ test: 23.23 }); + }); + + it('should preserve number value and type for negative values', () => { + expect(Schema.read(Schema.write({ test: -23.23 }))).toEqual({ test: -23.23 }); + }); + }); + + describe('Integer', () => { + const Schema = schema({ test: { type: 'integer', format: 'int32' } }); + + it('should preserve integer value and type', () => { + expect(Schema.read(Schema.write({ test: 123 }))).toEqual({ test: 123 }); + }); + + it('should preserve integer value and type for negative values', () => { + expect(Schema.read(Schema.write({ test: -456 }))).toEqual({ test: -456 }); + }); + }); + + describe('Integer (int64)', () => { + const Schema = schema({ test: { type: 'integer', format: 'int64' } }); + + it('should preserve int64 value and type', () => { + expect(Schema.read(Schema.write({ test: 9007199254740991 }))).toEqual({ test: 9007199254740991 }); + }); + + it('should preserve int64 value and type for negative values', () => { + expect(Schema.read(Schema.write({ test: -9007199254740991 }))).toEqual({ test: -9007199254740991 }); + }); + }); + + describe('Number (float)', () => { + const Schema = schema({ test: { type: 'number', format: 'float' } }); + + it('should preserve float value (with precision loss)', () => { + const result = Schema.read(Schema.write({ test: 3.14159 })); + expect(result.test).toBeCloseTo(3.14159, 5); + }); + + it('should preserve float value for negative values', () => { + const result = Schema.read(Schema.write({ test: -2.71828 })); + expect(result.test).toBeCloseTo(-2.71828, 5); + }); + }); + + describe('Plain Integer (no format)', () => { + const Schema = schema({ test: { type: 'integer' } }); + + it('should default to int32 format', () => { + expect(Schema.read(Schema.write({ test: 42 }))).toEqual({ test: 42 }); + }); + + it('should handle negative values', () => { + expect(Schema.read(Schema.write({ test: -42 }))).toEqual({ test: -42 }); + }); + }); + + describe('Plain Number (no format)', () => { + const Schema = schema({ test: { type: 'number' } }); + + it('should default to double format', () => { + expect(Schema.read(Schema.write({ test: 3.141592653589793 }))).toEqual({ test: 3.141592653589793 }); + }); + + it('should handle negative values', () => { + expect(Schema.read(Schema.write({ test: -2.718281828459045 }))).toEqual({ test: -2.718281828459045 }); + }); + }); + + describe('String', () => { + const Schema = schema({ test: { type: 'string' } }); + + it('should preserve string value and type', () => { + expect(Schema.read(Schema.write({ test: 'hello world' }))).toEqual({ test: 'hello world' }); + }); + + it('should support special characters', () => { + expect(Schema.read(Schema.write({ test: '한자' }))).toEqual({ test: '한자' }); + }); + + it('should support emojis', () => { + expect(Schema.read(Schema.write({ test: '🚀' }))).toEqual({ test: '🚀' }); + }); + }); + + describe('UUID', () => { + const Schema = schema({ test: { type: 'string', format: 'uuid' } }); + + it('should preserve UUID value', () => { + const uuid = '550e8400-e29b-4d4e-a7d4-426614174000'; + expect(Schema.read(Schema.write({ test: uuid }))).toEqual({ test: uuid }); + }); + + it('should compress UUID to 16 bytes instead of 72', () => { + const uuid = '550e8400-e29b-4d4e-a7d4-426614174000'; + const buffer = Schema.write({ test: uuid }); + // Header: 1 byte (field count) + 1 byte (field index) + 1 byte (size) = 3 bytes + // Content: 16 bytes (UUID binary) + // Total: 19 bytes (vs 75 bytes for string encoding: 3 header + 72 content) + expect(buffer.length).toBe(19); + }); + + it('should handle uppercase UUIDs', () => { + const uuid = '550E8400-E29B-4D4E-A7D4-426614174000'; + const result = Schema.read(Schema.write({ test: uuid })); + // UUID should be normalized to lowercase + expect(result.test).toBe('550e8400-e29b-4d4e-a7d4-426614174000'); + }); + + it('should handle nil UUID', () => { + const uuid = '00000000-0000-0000-0000-000000000000'; + expect(Schema.read(Schema.write({ test: uuid }))).toEqual({ test: uuid }); + }); + }); + + describe('IPv4', () => { + const Schema = schema({ test: { type: 'string', format: 'ipv4' } }); + + it('should preserve IPv4 value', () => { + const ip = '192.168.1.1'; + expect(Schema.read(Schema.write({ test: ip }))).toEqual({ test: ip }); + }); + + it('should compress IPv4 to 4 bytes instead of 30', () => { + const ip = '192.168.1.1'; + const buffer = Schema.write({ test: ip }); + // Header: 1 byte (field count) + 1 byte (field index) + 1 byte (size) = 3 bytes + // Content: 4 bytes (IPv4 binary) + // Total: 7 bytes (vs 33 bytes for string encoding) + expect(buffer.length).toBe(7); + }); + + it('should handle edge cases', () => { + expect(Schema.read(Schema.write({ test: '0.0.0.0' }))).toEqual({ test: '0.0.0.0' }); + expect(Schema.read(Schema.write({ test: '255.255.255.255' }))).toEqual({ test: '255.255.255.255' }); + }); + }); + + describe('IPv6', () => { + const Schema = schema({ test: { type: 'string', format: 'ipv6' } }); + + it('should compress IPv6 value', () => { + const ip = '2001:0db8:85a3:0000:0000:8a2e:0370:7334'; + expect(Schema.read(Schema.write({ test: ip }))).toEqual({ test: '2001:db8:85a3::8a2e:370:7334' }); + }); + + it('should compress IPv6 to 16 bytes instead of 78', () => { + const ip = '2001:0db8:85a3:0000:0000:8a2e:0370:7334'; + const buffer = Schema.write({ test: ip }); + // Header: 1 byte (field count) + 1 byte (field index) + 1 byte (size) = 3 bytes + // Content: 16 bytes (IPv6 binary) + // Total: 19 bytes (vs 81 bytes for string encoding) + expect(buffer.length).toBe(19); + }); + + it('should handle compressed IPv6 notation', () => { + const ip = '2001:db8:85a3::8a2e:370:7334'; + const result = Schema.read(Schema.write({ test: ip })); + // Should be decoded back with compression + expect(result.test).toBe(ip); + }); + + it('should handle loopback', () => { + const ip = '::1'; + const result = Schema.read(Schema.write({ test: ip })); + expect(result.test).toBe('::1'); + }); + + it('should handle all zeros', () => { + const ip = '::'; + const result = Schema.read(Schema.write({ test: ip })); + expect(result.test).toBe('::'); + }); + }); + + describe('Date', () => { + const Schema = schema({ test: { type: 'string', format: 'date' } }); + + it('should preserve date value', () => { + const date = '2025-10-28'; + expect(Schema.read(Schema.write({ test: date }))).toEqual({ test: date }); + }); + + it('should compress date to 4 bytes instead of 20', () => { + const date = '2025-10-28'; + const buffer = Schema.write({ test: date }); + // Header: 1 byte (field count) + 1 byte (field index) + 1 byte (size) = 3 bytes + // Content: 4 bytes (days since epoch) + // Total: 7 bytes (vs 23 bytes for string encoding) + expect(buffer.length).toBe(7); + }); + + it('should handle epoch date', () => { + const date = '1970-01-01'; + expect(Schema.read(Schema.write({ test: date }))).toEqual({ test: date }); + }); + + it('should handle dates before epoch', () => { + const date = '1969-12-31'; + expect(Schema.read(Schema.write({ test: date }))).toEqual({ test: date }); + }); + + it('should handle far future dates', () => { + const date = '2099-12-31'; + expect(Schema.read(Schema.write({ test: date }))).toEqual({ test: date }); + }); + }); + + describe('DateTime', () => { + const Schema = schema({ test: { type: 'string', format: 'date-time' } }); + + it('should preserve date-time value', () => { + const datetime = '2025-10-28T14:30:00.000Z'; + expect(Schema.read(Schema.write({ test: datetime }))).toEqual({ test: datetime }); + }); + + it('should compress date-time to 8 bytes instead of 40+', () => { + const datetime = '2025-10-28T14:30:00.000Z'; + const buffer = Schema.write({ test: datetime }); + // Header: 1 byte (field count) + 1 byte (field index) + 1 byte (size) = 3 bytes + // Content: 9 bytes (milliseconds since epoch) + // Total: 12 bytes (vs 43+ bytes for string encoding) + expect(buffer.length).toBe(12); + }); + + it('should handle epoch datetime', () => { + const datetime = '1970-01-01T00:00:00.000Z'; + expect(Schema.read(Schema.write({ test: datetime }))).toEqual({ test: datetime }); + }); + + it('should handle millisecond precision', () => { + const datetime = '2025-10-28T14:30:00.123Z'; + expect(Schema.read(Schema.write({ test: datetime }))).toEqual({ test: datetime }); + }); + + it('should normalize various ISO 8601 formats', () => { + // Input without milliseconds, output should have .000Z + const input = '2025-10-28T14:30:00Z'; + const result = Schema.read(Schema.write({ test: input })); + expect(result.test).toBe('2025-10-28T14:30:00.000Z'); + }); + }); + + describe('Binary', () => { + const Schema = schema({ test: { type: 'string', format: 'binary' } }); + + it('should preserve binary data via base64', () => { + const base64 = 'SGVsbG8gV29ybGQh'; // "Hello World!" + expect(Schema.read(Schema.write({ test: base64 }))).toEqual({ test: base64 }); + }); + + it('should compress binary data efficiently', () => { + const base64 = 'SGVsbG8gV29ybGQh'; // 16 chars = 32 bytes as string + const buffer = Schema.write({ test: base64 }); + // Header: 1 byte (field count) + 1 byte (field index) + 4 bytes (size) = 6 bytes + // Content: 12 bytes (raw binary data decoded from base64) + // Total: 18 bytes (vs 35 bytes for string encoding) + expect(buffer.length).toBe(18); + }); + + it('should handle Buffer input', () => { + const data = Buffer.from('Hello World!', 'utf8'); + const result = Schema.read(Schema.write({ test: data })); + expect(result.test).toBe('SGVsbG8gV29ybGQh'); + }); + + it('should handle Uint8Array input', () => { + const data = new Uint8Array([72, 101, 108, 108, 111]); + const result = Schema.read(Schema.write({ test: data })); + expect(result.test).toBe('SGVsbG8='); // "Hello" in base64 + }); + + it('should handle empty binary data', () => { + const base64 = ''; // Empty + expect(Schema.read(Schema.write({ test: base64 }))).toEqual({ test: base64 }); + }); + + it('should handle large binary data', () => { + // Create 256 bytes of data + const bytes = new Uint8Array(256); + for (let i = 0; i < 256; i++) { + bytes[i] = i; + } + const base64 = Buffer.from(bytes).toString('base64'); + expect(Schema.read(Schema.write({ test: base64 }))).toEqual({ test: base64 }); + }); + }); + + describe('Array', () => { + const Schema = schema({ test: { type: 'array', items: { type: 'string' } } }); + + it('should preserve array values and types', () => { + expect(Schema.read(Schema.write({ test: ['a', 'b', 'c'] }))).toEqual({ test: ['a', 'b', 'c'] }); + }); + }); + + describe('Schema', () => { + const Schema = schema({ test: { type: 'object', schema: { test: { type: 'number', format: 'double' } } } }); + + it('should preserve object values and types', () => { + expect(Schema.read(Schema.write({ test: { test: 23.23 } }))).toEqual({ test: { test: 23.23 } }); + }); + }); + + describe('OneOf', () => { + const Schema = schema({ + value: { + oneOf: [ + { type: 'string' }, + { type: 'integer', format: 'int32' }, + { type: 'boolean' }, + ], + }, + }); + + it('should handle string variant (first)', () => { + const data = { value: 'hello' }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + + it('should handle integer variant (second)', () => { + const data = { value: 42 }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + + it('should handle boolean variant (third)', () => { + const data = { value: true }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + + it('should use correct discriminator for each variant', () => { + // String (first variant) should have discriminator 0x01 + const stringBuffer = Schema.write({ value: 'test' }); + expect(stringBuffer[2]).toBe(0x01); // discriminator byte + + // Integer (second variant) should have discriminator 0x02 + const intBuffer = Schema.write({ value: 42 }); + expect(intBuffer[2]).toBe(0x02); // discriminator byte + + // Boolean (third variant) should have discriminator 0x03 + const boolBuffer = Schema.write({ value: true }); + expect(boolBuffer[2]).toBe(0x03); // discriminator byte + }); + }); + + describe('AnyOf', () => { + const Schema = schema({ + data: { + anyOf: [ + { type: 'number', format: 'double' }, + { type: 'string' }, + ], + }, + }); + + it('should handle number variant', () => { + const data = { data: 3.14 }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + + it('should handle string variant', () => { + const data = { data: 'hello' }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + }); + + describe('OneOf with nullable', () => { + const Schema = schema({ + value: { + nullable: true, + oneOf: [ + { type: 'string' }, + { type: 'integer', format: 'int32' }, + ], + }, + }); + + it('should handle null value', () => { + const data = { value: null }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + + it('should handle string variant when not null', () => { + const data = { value: 'test' }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + + it('should handle integer variant when not null', () => { + const data = { value: 123 }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + + it('should use 0x00 for null, 0x01+ for variants', () => { + // Null should use 0x00 + const nullBuffer = Schema.write({ value: null }); + expect(nullBuffer[2]).toBe(0x00); + + // String variant should use 0x01 + const stringBuffer = Schema.write({ value: 'test' }); + expect(stringBuffer[2]).toBe(0x01); + + // Integer variant should use 0x02 + const intBuffer = Schema.write({ value: 42 }); + expect(intBuffer[2]).toBe(0x02); + }); + }); + + describe('OneOf with complex types', () => { + const Schema = schema({ + item: { + oneOf: [ + { type: 'array', items: { type: 'string' } }, + { type: 'object', schema: { x: { type: 'integer', format: 'int32' }, y: { type: 'integer', format: 'int32' } } }, + ], + }, + }); + + it('should handle array variant', () => { + const data = { item: ['a', 'b', 'c'] }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + + it('should handle object variant', () => { + const data = { item: { x: 10, y: 20 } }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + }); +}); + +describe('Data integrity - multi simple', () => { + describe('Booleans', () => { + const Schema = schema({ test: { type: 'boolean' }, test2: { type: 'boolean' } }); + + it('should preserve boolean value and type - false', () => { + expect(Schema.read(Schema.write({ test: false, test2: true }))).toEqual({ test: false, test2: true }); + }); + + it('should skip null or undefined values', () => { + expect(Schema.read(Schema.write({ test: null, test2: false }))).toEqual({ test2: false }); + }); + }); + + describe('Numbers', () => { + const Schema = schema({ test: { type: 'number', format: 'double' }, test2: { type: 'number', format: 'double' } }); + + it('should preserve number value and type', () => { + expect(Schema.read(Schema.write({ test: 23.23, test2: -97.7 }))).toEqual({ test: 23.23, test2: -97.7 }); + }); + }); + + describe('Strings', () => { + const Schema = schema({ test: { type: 'string' }, test2: { type: 'string' } }); + + it('should preserve string value and type', () => { + expect(Schema.read(Schema.write({ test: 'hello world', test2: 'écho' }))).toEqual({ test: 'hello world', test2: 'écho' }); + }); + }); + + describe('Arrays', () => { + const Schema = schema({ test: { type: 'array', items: { type: 'string' } }, test2: { type: 'array', items: { type: 'string' } } }); + + it('should preserve array values and types', () => { + expect(Schema.read(Schema.write({ test: ['a', 'b', 'c'], test2: ['d', 'e', 'f'] }))).toEqual({ test: ['a', 'b', 'c'], test2: ['d', 'e', 'f'] }); + }); + }); + + describe('Schemas', () => { + const Schema = schema({ test: { type: 'object', schema: { test: { type: 'number', format: 'double' } } }, test2: { type: 'object', schema: { test: { type: 'number', format: 'double' } } } }); + + it('should preserve object values and types', () => { + expect(Schema.read(Schema.write({ test: { test: 23.23 }, test2: { test: -97.7 } }))).toEqual({ test: { test: 23.23 }, test2: { test: -97.7 } }); + }); + }); +}); + +describe('Data integrity - multi mixed', () => { + describe('Boolean + number + string + array + object', () => { + const Schema = schema({ + bool: { type: 'boolean' }, + num: { type: 'number', format: 'double' }, + str: { type: 'string' }, + arr: { type: 'array', items: { type: 'string' } }, + obj: { type: 'object', schema: { sub: { type: 'string' } } }, + }); + + it('should preserve values and types', () => { + expect(Schema.read(Schema.write({ bool: true, num: 23.23, str: 'hello world', arr: ['a', 'b', 'c'], obj: { sub: 'way' } }))).toEqual({ bool: true, num: 23.23, str: 'hello world', arr: ['a', 'b', 'c'], obj: { sub: 'way' } }); + }); + }); +}); + +/* Size comparison tests ----------------------------------------------------- */ + +describe('Format size differences', () => { + describe('Float vs Double', () => { + const FloatSchema = schema({ value: { type: 'number', format: 'float' } }); + const DoubleSchema = schema({ value: { type: 'number', format: 'double' } }); + + it('float should use 4 bytes for value (7 bytes total with metadata)', () => { + const buffer = FloatSchema.write({ value: 3.14 }); + expect(buffer.length).toBe(7); // 1 field count + 1 field index + 1 size + 4 value + }); + + it('double should use 8 bytes for value (11 bytes total with metadata)', () => { + const buffer = DoubleSchema.write({ value: 3.14 }); + expect(buffer.length).toBe(11); // 1 field count + 1 field index + 1 size + 8 value + }); + }); + + describe('Int32 vs Int64', () => { + const Int32Schema = schema({ value: { type: 'integer', format: 'int32' } }); + const Int64Schema = schema({ value: { type: 'integer', format: 'int64' } }); + + it('int32 should use 4 bytes for value (7 bytes total with metadata)', () => { + const buffer = Int32Schema.write({ value: 12345 }); + expect(buffer.length).toBe(7); // 1 field count + 1 field index + 1 size + 4 value + }); + + it('int64 should use 8 bytes for value (11 bytes total with metadata)', () => { + const buffer = Int64Schema.write({ value: 12345 }); + expect(buffer.length).toBe(11); // 1 field count + 1 field index + 1 size + 8 value + }); + }); +}); + +/* Nullable properties tests ------------------------------------------------- */ + +describe('Nullable properties', () => { + describe('Nullable string', () => { + const Schema = schema({ test: { type: 'string', nullable: true } }); + + it('should preserve null value', () => { + expect(Schema.read(Schema.write({ test: null }))).toEqual({ test: null }); + }); + + it('should preserve non-null string value', () => { + expect(Schema.read(Schema.write({ test: 'hello' }))).toEqual({ test: 'hello' }); + }); + + it('should encode null with minimal bytes (header only)', () => { + const buffer = Schema.write({ test: null }); + const nonNullBuffer = Schema.write({ test: 'a' }); + expect(buffer.length).toBeLessThan(nonNullBuffer.length); + }); + }); + + describe('Nullable number', () => { + const Schema = schema({ test: { type: 'number', format: 'double', nullable: true } }); + + it('should preserve null value', () => { + expect(Schema.read(Schema.write({ test: null }))).toEqual({ test: null }); + }); + + it('should preserve non-null number value', () => { + expect(Schema.read(Schema.write({ test: 42.5 }))).toEqual({ test: 42.5 }); + }); + }); + + describe('Nullable integer', () => { + const Schema = schema({ test: { type: 'integer', format: 'int32', nullable: true } }); + + it('should preserve null value', () => { + expect(Schema.read(Schema.write({ test: null }))).toEqual({ test: null }); + }); + + it('should preserve non-null integer value', () => { + expect(Schema.read(Schema.write({ test: 123 }))).toEqual({ test: 123 }); + }); + }); + + describe('Nullable boolean', () => { + const Schema = schema({ test: { type: 'boolean', nullable: true } }); + + it('should preserve null value', () => { + expect(Schema.read(Schema.write({ test: null }))).toEqual({ test: null }); + }); + + it('should preserve false value (not confused with null)', () => { + expect(Schema.read(Schema.write({ test: false }))).toEqual({ test: false }); + }); + + it('should preserve true value', () => { + expect(Schema.read(Schema.write({ test: true }))).toEqual({ test: true }); + }); + }); + + describe('Mixed nullable and non-nullable', () => { + const Schema = schema({ + nullableField: { type: 'string', nullable: true }, + regularField: { type: 'string' }, + anotherNullable: { type: 'integer', format: 'int32', nullable: true }, + }); + + it('should handle mix of null and non-null values', () => { + const data = { nullableField: null, regularField: 'hello', anotherNullable: 42 }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + + it('should skip non-nullable fields when null', () => { + const data = { nullableField: 'test', regularField: null, anotherNullable: null }; + const result = Schema.read(Schema.write(data)); + expect(result).toEqual({ nullableField: 'test', anotherNullable: null }); + expect(result.regularField).toBeUndefined(); + }); + + it('should preserve all null values in nullable fields', () => { + const data = { nullableField: null, regularField: 'value', anotherNullable: null }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + }); + + describe('Nullable object', () => { + const Schema = schema({ + test: { + type: 'object', + nullable: true, + schema: { name: { type: 'string' } }, + }, + }); + + it('should preserve null value', () => { + expect(Schema.read(Schema.write({ test: null }))).toEqual({ test: null }); + }); + + it('should preserve non-null object value', () => { + expect(Schema.read(Schema.write({ test: { name: 'John' } }))).toEqual({ test: { name: 'John' } }); + }); + }); + + describe('Nullable array', () => { + const Schema = schema({ + test: { + type: 'array', + nullable: true, + items: { type: 'string' }, + }, + }); + + it('should preserve null value', () => { + expect(Schema.read(Schema.write({ test: null }))).toEqual({ test: null }); + }); + + it('should preserve non-null array value', () => { + expect(Schema.read(Schema.write({ test: ['a', 'b', 'c'] }))).toEqual({ test: ['a', 'b', 'c'] }); + }); + + it('should preserve empty array (different from null)', () => { + expect(Schema.read(Schema.write({ test: [] }))).toEqual({ test: [] }); + }); + + it('empty array should have different encoding than null', () => { + const emptyArrayBuffer = Schema.write({ test: [] }); + const nullBuffer = Schema.write({ test: null }); + expect(emptyArrayBuffer).not.toEqual(nullBuffer); + }); + }); + + describe('Empty vs null distinction', () => { + describe('Empty string vs null', () => { + const Schema = schema({ test: { type: 'string', nullable: true } }); + + it('should distinguish empty string from null', () => { + const emptyString = Schema.read(Schema.write({ test: '' })); + const nullValue = Schema.read(Schema.write({ test: null })); + + expect(emptyString).toEqual({ test: '' }); + expect(nullValue).toEqual({ test: null }); + expect(emptyString.test).not.toBe(nullValue.test); + }); + + it('should have different byte encodings', () => { + const emptyStringBuffer = Schema.write({ test: '' }); + const nullBuffer = Schema.write({ test: null }); + expect(emptyStringBuffer).not.toEqual(nullBuffer); + }); + }); + + describe('Zero vs null for numbers', () => { + const Schema = schema({ test: { type: 'number', format: 'double', nullable: true } }); + + it('should distinguish zero from null', () => { + const zero = Schema.read(Schema.write({ test: 0 })); + const nullValue = Schema.read(Schema.write({ test: null })); + + expect(zero).toEqual({ test: 0 }); + expect(nullValue).toEqual({ test: null }); + expect(zero.test).not.toBe(nullValue.test); + }); + }); + + describe('False vs null for booleans', () => { + const Schema = schema({ test: { type: 'boolean', nullable: true } }); + + it('should distinguish false from null', () => { + const falseValue = Schema.read(Schema.write({ test: false })); + const nullValue = Schema.read(Schema.write({ test: null })); + + expect(falseValue).toEqual({ test: false }); + expect(nullValue).toEqual({ test: null }); + expect(falseValue.test).not.toBe(nullValue.test); + }); + }); + }); +}); + +/* OpenAPI Array Format Tests ----------------------------------------------- */ + +describe('OpenAPI-compatible array formats', () => { + describe('Array of integers (int32)', () => { + const Schema = schema({ test: { type: 'array', items: { type: 'integer', format: 'int32' } } }); + + it('should preserve array of integers', () => { + expect(Schema.read(Schema.write({ test: [1, 2, 3, 4, 5] }))).toEqual({ test: [1, 2, 3, 4, 5] }); + }); + + it('should handle negative integers', () => { + expect(Schema.read(Schema.write({ test: [-100, 0, 100] }))).toEqual({ test: [-100, 0, 100] }); + }); + }); + + describe('Array of integers (int64)', () => { + const Schema = schema({ test: { type: 'array', items: { type: 'integer', format: 'int64' } } }); + + it('should preserve array of int64 values', () => { + const data = { test: [9007199254740991, -9007199254740991, 0] }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + }); + + describe('Array of floats', () => { + const Schema = schema({ test: { type: 'array', items: { type: 'number', format: 'float' } } }); + + it('should preserve array of floats with precision loss', () => { + const result = Schema.read(Schema.write({ test: [3.14, 2.71, 1.41] })); + expect(result.test[0]).toBeCloseTo(3.14, 5); + expect(result.test[1]).toBeCloseTo(2.71, 5); + expect(result.test[2]).toBeCloseTo(1.41, 5); + }); + }); + + describe('Array of doubles', () => { + const Schema = schema({ test: { type: 'array', items: { type: 'number', format: 'double' } } }); + + it('should preserve array of doubles', () => { + expect(Schema.read(Schema.write({ test: [3.141592653589793, 2.718281828459045] }))).toEqual({ test: [3.141592653589793, 2.718281828459045] }); + }); + }); + + describe('Array of UUIDs', () => { + const Schema = schema({ test: { type: 'array', items: { type: 'string', format: 'uuid' } } }); + + it('should preserve array of UUIDs', () => { + const data = { + test: [ + '550e8400-e29b-4d4e-a7d4-426614174000', + '6ba7b810-9dad-11d1-80b4-00c04fd430c8', + '00000000-0000-0000-0000-000000000000', + ], + }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + + it('should compress UUIDs efficiently', () => { + const data = { + test: ['550e8400-e29b-4d4e-a7d4-426614174000', '6ba7b810-9dad-11d1-80b4-00c04fd430c8'], + }; + const buffer = Schema.write(data); + // Each UUID is 16 bytes + 1 byte size = 17 bytes per UUID + // Plus array overhead + expect(buffer.length).toBeLessThan(100); // Much less than string encoding + }); + }); + + describe('Array of IPv4 addresses', () => { + const Schema = schema({ test: { type: 'array', items: { type: 'string', format: 'ipv4' } } }); + + it('should preserve array of IPv4 addresses', () => { + const data = { test: ['192.168.1.1', '10.0.0.1', '172.16.0.1'] }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + }); + + describe('Array of IPv6 addresses', () => { + const Schema = schema({ test: { type: 'array', items: { type: 'string', format: 'ipv6' } } }); + + it('should preserve array of IPv6 addresses', () => { + const data = { + test: ['2001:db8:85a3::8a2e:370:7334', '::1', '::'], + }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + }); + + describe('Array of dates', () => { + const Schema = schema({ test: { type: 'array', items: { type: 'string', format: 'date' } } }); + + it('should preserve array of dates', () => { + const data = { test: ['2025-10-28', '2024-01-01', '1970-01-01'] }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + }); + + describe('Array of date-times', () => { + const Schema = schema({ test: { type: 'array', items: { type: 'string', format: 'date-time' } } }); + + it('should preserve array of date-times', () => { + const data = { + test: ['2025-10-28T14:30:00.000Z', '2024-01-01T00:00:00.000Z'], + }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + }); + + describe('Array of binary data', () => { + const Schema = schema({ test: { type: 'array', items: { type: 'string', format: 'binary' } } }); + + it('should preserve array of binary data', () => { + const data = { test: ['SGVsbG8=', 'V29ybGQ=', 'Zm9vYmFy'] }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + + it('should handle Buffer inputs', () => { + const input = { test: [Buffer.from('Hello'), Buffer.from('World')] }; + const result = Schema.read(Schema.write(input)); + expect(result.test).toEqual(['SGVsbG8=', 'V29ybGQ=']); + }); + }); + + describe('Array with nullable items', () => { + const Schema = schema({ test: { type: 'array', items: { type: 'string', nullable: true } } }); + + it('should preserve null values in array', () => { + const data = { test: ['a', null, 'b', null, 'c'] }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + + it('should distinguish empty string from null', () => { + const data = { test: ['', null, 'text'] }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + + it('should handle all null array', () => { + const data = { test: [null, null, null] }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + }); + + describe('Array with nullable integer items', () => { + const Schema = schema({ test: { type: 'array', items: { type: 'integer', format: 'int32', nullable: true } } }); + + it('should preserve null values with integers', () => { + const data = { test: [1, null, 2, null, 3] }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + + it('should distinguish zero from null', () => { + const data = { test: [0, null, -1] }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + }); + + describe('Array with oneOf items', () => { + const Schema = schema({ + test: { + type: 'array', + items: { + oneOf: [ + { type: 'string' }, + { type: 'integer', format: 'int32' }, + { type: 'boolean' }, + ], + }, + }, + }); + + it('should handle mixed types in array', () => { + const data = { test: ['hello', 42, true, 'world', false, 123] }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + + it('should handle all strings', () => { + const data = { test: ['a', 'b', 'c'] }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + + it('should handle all integers', () => { + const data = { test: [1, 2, 3] }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + + it('should handle all booleans', () => { + const data = { test: [true, false, true] }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + }); + + describe('Array with anyOf items', () => { + const Schema = schema({ + test: { + type: 'array', + items: { + anyOf: [ + { type: 'number', format: 'double' }, + { type: 'string' }, + ], + }, + }, + }); + + it('should handle mixed numbers and strings', () => { + const data = { test: [3.14, 'pi', 2.71, 'e'] }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + }); + + describe('Array with oneOf and nullable items', () => { + const Schema = schema({ + test: { + type: 'array', + items: { + nullable: true, + oneOf: [ + { type: 'string' }, + { type: 'integer', format: 'int32' }, + ], + }, + }, + }); + + it('should handle null with oneOf variants', () => { + const data = { test: ['hello', null, 42, null, 'world'] }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + }); + + describe('Nested arrays', () => { + const Schema = schema({ + test: { + type: 'array', + items: { + type: 'array', + items: { type: 'integer', format: 'int32' }, + }, + }, + }); + + it('should handle 2D arrays', () => { + const data = { test: [[1, 2, 3], [4, 5, 6], [7, 8, 9]] }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + + it('should handle empty nested arrays', () => { + const data = { test: [[], [1], []] }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + }); + + describe('Nested arrays with strings', () => { + const Schema = schema({ + test: { + type: 'array', + items: { + type: 'array', + items: { type: 'string' }, + }, + }, + }); + + it('should handle 2D string arrays', () => { + const data = { test: [['a', 'b'], ['c', 'd', 'e'], ['f']] }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + }); + + describe('Arrays of objects', () => { + const Schema = schema({ + test: { + type: 'array', + items: { + type: 'object', + schema: { + x: { type: 'integer', format: 'int32' }, + y: { type: 'integer', format: 'int32' }, + }, + }, + }, + }); + + it('should handle array of objects', () => { + const data = { test: [{ x: 1, y: 2 }, { x: 3, y: 4 }, { x: 5, y: 6 }] }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + }); + + describe('Complex nested structure', () => { + const Schema = schema({ + test: { + type: 'array', + items: { + oneOf: [ + { type: 'string' }, + { type: 'array', items: { type: 'integer', format: 'int32' } }, + { type: 'object', schema: { name: { type: 'string' } } }, + ], + }, + }, + }); + + it('should handle complex nested structures with oneOf', () => { + const data = { + test: [ + 'hello', + [1, 2, 3], + { name: 'test' }, + 'world', + [4, 5], + ], + }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + }); +}); + +/* OpenAPI Object Format Tests ---------------------------------------------- */ + +describe('OpenAPI-compatible object formats', () => { + describe('Object with various format types', () => { + const Schema = schema({ + user: { + type: 'object', + schema: { + id: { type: 'string', format: 'uuid' }, + name: { type: 'string' }, + age: { type: 'integer', format: 'int32' }, + balance: { type: 'number', format: 'double' }, + active: { type: 'boolean' }, + created: { type: 'string', format: 'date-time' }, + ip: { type: 'string', format: 'ipv4' }, + }, + }, + }); + + it('should preserve object with mixed format types', () => { + const data = { + user: { + id: '550e8400-e29b-4d4e-a7d4-426614174000', + name: 'John Doe', + age: 30, + balance: 1234.56, + active: true, + created: '2025-10-28T14:30:00.000Z', + ip: '192.168.1.1', + }, + }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + }); + + describe('Object with nullable properties', () => { + const Schema = schema({ + data: { + type: 'object', + schema: { + required: { type: 'string' }, + optional: { type: 'string', nullable: true }, + number: { type: 'integer', format: 'int32', nullable: true }, + }, + }, + }); + + it('should preserve null values in object properties', () => { + const data = { + data: { + required: 'value', + optional: null, + number: null, + }, + }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + + it('should preserve non-null values', () => { + const data = { + data: { + required: 'value', + optional: 'text', + number: 42, + }, + }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + + it('should handle mix of null and non-null', () => { + const data = { + data: { + required: 'value', + optional: 'text', + number: null, + }, + }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + }); + + describe('Object with oneOf properties', () => { + const Schema = schema({ + response: { + type: 'object', + schema: { + status: { type: 'integer', format: 'int32' }, + data: { + oneOf: [ + { type: 'string' }, + { type: 'integer', format: 'int32' }, + { type: 'object', schema: { message: { type: 'string' } } }, + ], + }, + }, + }, + }); + + it('should handle string variant', () => { + const data = { + response: { + status: 200, + data: 'success', + }, + }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + + it('should handle integer variant', () => { + const data = { + response: { + status: 200, + data: 42, + }, + }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + + it('should handle object variant', () => { + const data = { + response: { + status: 200, + data: { message: 'Operation completed' }, + }, + }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + }); + + describe('Object with anyOf properties', () => { + const Schema = schema({ + item: { + type: 'object', + schema: { + id: { type: 'integer', format: 'int32' }, + value: { + anyOf: [ + { type: 'number', format: 'double' }, + { type: 'string' }, + ], + }, + }, + }, + }); + + it('should handle number variant', () => { + const data = { + item: { + id: 1, + value: 3.14159, + }, + }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + + it('should handle string variant', () => { + const data = { + item: { + id: 1, + value: 'text value', + }, + }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + }); + + describe('Deeply nested objects', () => { + const Schema = schema({ + root: { + type: 'object', + schema: { + level1: { + type: 'object', + schema: { + level2: { + type: 'object', + schema: { + level3: { + type: 'object', + schema: { + value: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + }, + }); + + it('should handle deeply nested objects', () => { + const data = { + root: { + level1: { + level2: { + level3: { + value: 'deep', + }, + }, + }, + }, + }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + }); + + describe('Object with array properties', () => { + const Schema = schema({ + data: { + type: 'object', + schema: { + tags: { type: 'array', items: { type: 'string' } }, + scores: { type: 'array', items: { type: 'integer', format: 'int32' } }, + metadata: { + type: 'array', + items: { + type: 'object', + schema: { + key: { type: 'string' }, + value: { type: 'string' }, + }, + }, + }, + }, + }, + }); + + it('should handle objects with array properties', () => { + const data = { + data: { + tags: ['typescript', 'serialization'], + scores: [10, 20, 30], + metadata: [ + { key: 'author', value: 'John' }, + { key: 'version', value: '1.0' }, + ], + }, + }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + }); + + describe('Object with nullable oneOf properties', () => { + const Schema = schema({ + record: { + type: 'object', + schema: { + id: { type: 'integer', format: 'int32' }, + value: { + nullable: true, + oneOf: [ + { type: 'string' }, + { type: 'integer', format: 'int32' }, + ], + }, + }, + }, + }); + + it('should handle null in nullable oneOf', () => { + const data = { + record: { + id: 1, + value: null, + }, + }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + + it('should handle string variant', () => { + const data = { + record: { + id: 1, + value: 'text', + }, + }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + + it('should handle integer variant', () => { + const data = { + record: { + id: 1, + value: 42, + }, + }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + }); + + describe('Complex object with all OpenAPI features', () => { + const Schema = schema({ + entity: { + type: 'object', + schema: { + id: { type: 'string', format: 'uuid' }, + type: { type: 'string' }, + attributes: { + type: 'object', + schema: { + name: { type: 'string' }, + age: { type: 'integer', format: 'int32', nullable: true }, + balance: { type: 'number', format: 'double' }, + }, + }, + tags: { type: 'array', items: { type: 'string' } }, + metadata: { + oneOf: [ + { type: 'string' }, + { type: 'object', schema: { key: { type: 'string' }, value: { type: 'string' } } }, + ], + }, + created: { type: 'string', format: 'date-time' }, + }, + }, + }); + + it('should handle complex object with all features', () => { + const data = { + entity: { + id: '550e8400-e29b-4d4e-a7d4-426614174000', + type: 'user', + attributes: { + name: 'John Doe', + age: null, + balance: 1234.56, + }, + tags: ['active', 'premium'], + metadata: { key: 'region', value: 'us-east-1' }, + created: '2025-10-28T14:30:00.000Z', + }, + }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + + it('should handle with string metadata variant', () => { + const data = { + entity: { + id: '550e8400-e29b-4d4e-a7d4-426614174000', + type: 'user', + attributes: { + name: 'Jane Doe', + age: 25, + balance: 5678.90, + }, + tags: ['new'], + metadata: 'simple string metadata', + created: '2025-10-28T15:00:00.000Z', + }, + }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + }); + + describe('Property order independence', () => { + const Schema = schema({ + data: { + type: 'object', + schema: { + first: { type: 'string' }, + second: { type: 'integer', format: 'int32' }, + third: { type: 'boolean' }, + fourth: { type: 'number', format: 'double' }, + }, + }, + }); + + it('should handle properties in schema order', () => { + const data = { + data: { + first: 'value1', + second: 42, + third: true, + fourth: 3.14, + }, + }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + + it('should handle properties in different order than schema', () => { + const data = { + data: { + fourth: 3.14, + first: 'value1', + third: true, + second: 42, + }, + }; + const expected = { + data: { + first: 'value1', + second: 42, + third: true, + fourth: 3.14, + }, + }; + expect(Schema.read(Schema.write(data))).toEqual(expected); + }); + + it('should handle properties in reverse order', () => { + const data = { + data: { + fourth: 2.71, + third: false, + second: 100, + first: 'reversed', + }, + }; + const expected = { + data: { + first: 'reversed', + second: 100, + third: false, + fourth: 2.71, + }, + }; + expect(Schema.read(Schema.write(data))).toEqual(expected); + }); + + it('should handle properties in random order', () => { + const data = { + data: { + third: true, + first: 'random', + fourth: 1.41, + second: 7, + }, + }; + const expected = { + data: { + first: 'random', + second: 7, + third: true, + fourth: 1.41, + }, + }; + expect(Schema.read(Schema.write(data))).toEqual(expected); + }); + }); + + describe('Undeclared properties validation', () => { + const Schema = schema({ + data: { + type: 'object', + schema: { + declared: { type: 'string' }, + }, + }, + }); + + it('should ignore undeclared properties', () => { + const warnSpy = jest.spyOn(logger, 'log').mockImplementation(); + + const input = { + data: { + declared: 'value', + undeclared: 'should be ignored', + }, + }; + const result = Schema.read(Schema.write(input)); + expect(result).toEqual({ data: { declared: 'value' } }); + expect(result.data).not.toHaveProperty('undeclared'); + + warnSpy.mockRestore(); + }); + + it('should always warn about undeclared properties', () => { + const warnSpy = jest.spyOn(logger, 'log').mockImplementation(); + + const input = { + data: { + declared: 'value', + extra1: 'ignored', + extra2: 'also ignored', + }, + }; + + Schema.write(input); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('undeclared properties'), + ); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('extra1, extra2'), + ); + + warnSpy.mockRestore(); + }); + + it('should not warn when all properties are declared', () => { + const warnSpy = jest.spyOn(logger, 'log').mockImplementation(); + + const input = { + data: { + declared: 'value', + }, + }; + + Schema.write(input); + + expect(warnSpy).not.toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); + }); +}); + +/* OpenAPI Native Format Tests ---------------------------------------------- */ + +describe('OpenAPI native format support', () => { + describe('Using properties instead of schema', () => { + const Schema = schema({ + user: { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + name: { type: 'string' }, + age: { type: 'integer', format: 'int32' }, + }, + }, + }); + + it('should handle properties field (OpenAPI format)', () => { + const data = { + user: { + id: '550e8400-e29b-4d4e-a7d4-426614174000', + name: 'John Doe', + age: 30, + }, + }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + }); + + describe('Using $ref for schema references', () => { + const Schema = schema( + { + user: { + $ref: '#/User', + }, + }, + { + schemas: { + User: { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + name: { type: 'string' }, + email: { type: 'string' }, + }, + }, + }, + }, + ); + + it('should resolve $ref to schema definition', () => { + const data = { + user: { + id: '550e8400-e29b-4d4e-a7d4-426614174000', + name: 'John Doe', + email: 'john@example.com', + }, + }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + }); + + describe('Nested $ref usage', () => { + const Schema = schema( + { + order: { + type: 'object', + properties: { + id: { type: 'integer', format: 'int32' }, + customer: { $ref: '#/Customer' }, + items: { + type: 'array', + items: { $ref: '#/Product' }, + }, + }, + }, + }, + { + schemas: { + Customer: { + type: 'object', + properties: { + name: { type: 'string' }, + email: { type: 'string' }, + }, + }, + Product: { + type: 'object', + properties: { + sku: { type: 'string' }, + price: { type: 'number', format: 'double' }, + }, + }, + }, + }, + ); + + it('should resolve nested $ref in objects and arrays', () => { + const data = { + order: { + id: 12345, + customer: { + name: 'Jane Doe', + email: 'jane@example.com', + }, + items: [ + { sku: 'PROD-001', price: 29.99 }, + { sku: 'PROD-002', price: 49.99 }, + ], + }, + }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + }); + + describe('$ref in oneOf/anyOf', () => { + const Schema = schema( + { + payment: { + type: 'object', + properties: { + id: { type: 'integer', format: 'int32' }, + method: { + oneOf: [{ $ref: '#/CreditCard' }, { $ref: '#/BankAccount' }], + }, + }, + }, + }, + { + schemas: { + CreditCard: { + type: 'object', + properties: { + cardNumber: { type: 'string' }, + expiry: { type: 'string' }, + }, + }, + BankAccount: { + type: 'object', + properties: { + accountNumber: { type: 'string' }, + routingNumber: { type: 'string' }, + }, + }, + }, + }, + ); + + it('should resolve $ref in oneOf (credit card)', () => { + const data = { + payment: { + id: 1, + method: { + cardNumber: '4111111111111111', + expiry: '12/25', + }, + }, + }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + + it('should resolve $ref in oneOf (bank account)', () => { + const data = { + payment: { + id: 1, + method: { + accountNumber: '123456789', + routingNumber: '987654321', + }, + }, + }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + }); + + describe('Complex OpenAPI schema', () => { + const Schema = schema( + { + response: { + type: 'object', + properties: { + status: { type: 'integer', format: 'int32' }, + data: { $ref: '#/UserResponse' }, + metadata: { + type: 'object', + properties: { + timestamp: { type: 'string', format: 'date-time' }, + requestId: { type: 'string', format: 'uuid' }, + }, + }, + }, + }, + }, + { + schemas: { + UserResponse: { + type: 'object', + properties: { + user: { $ref: '#/User' }, + permissions: { + type: 'array', + items: { type: 'string' }, + }, + }, + }, + User: { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + name: { type: 'string' }, + email: { type: 'string' }, + profile: { $ref: '#/Profile' }, + }, + }, + Profile: { + type: 'object', + properties: { + bio: { type: 'string', nullable: true }, + avatar: { type: 'string', format: 'binary' }, + settings: { + type: 'object', + properties: { + theme: { type: 'string' }, + notifications: { type: 'boolean' }, + }, + }, + }, + }, + }, + }, + ); + + it('should handle complex nested OpenAPI schema', () => { + const data = { + response: { + status: 200, + data: { + user: { + id: '550e8400-e29b-4d4e-a7d4-426614174000', + name: 'John Doe', + email: 'john@example.com', + profile: { + bio: null, + avatar: 'SGVsbG8gV29ybGQh', + settings: { + theme: 'dark', + notifications: true, + }, + }, + }, + permissions: ['read', 'write', 'admin'], + }, + metadata: { + timestamp: '2025-10-28T14:30:00.000Z', + requestId: '6ba7b810-9dad-11d1-80b4-00c04fd430c8', + }, + }, + }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + }); + + describe('Mixed format (properties and schema)', () => { + const Schema = schema( + { + // Using properties (OpenAPI format) + user: { + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + }, + }, + // Using schema (internal format) + product: { + type: 'object', + schema: { + sku: { type: 'string' }, + price: { type: 'number', format: 'double' }, + }, + }, + // Using $ref + order: { + $ref: '#/Order', + }, + }, + { + schemas: { + Order: { + type: 'object', + properties: { + id: { type: 'integer', format: 'int32' }, + total: { type: 'number', format: 'double' }, + }, + }, + }, + }, + ); + + it('should handle mixed schema formats', () => { + const data = { + user: { + id: 'user-123', + name: 'John', + }, + product: { + sku: 'PROD-001', + price: 29.99, + }, + order: { + id: 12345, + total: 99.99, + }, + }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + }); + + describe('Array with $ref items', () => { + const Schema = schema( + { + users: { + type: 'array', + items: { $ref: '#/User' }, + }, + }, + { + schemas: { + User: { + type: 'object', + properties: { + id: { type: 'integer', format: 'int32' }, + name: { type: 'string' }, + }, + }, + }, + }, + ); + + it('should handle array items with $ref', () => { + const data = { + users: [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + { id: 3, name: 'Charlie' }, + ], + }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + }); + + describe('Top-level schema object (OpenAPI format)', () => { + const Schema = schema({ + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + name: { type: 'string' }, + email: { type: 'string' }, + age: { type: 'integer', format: 'int32' }, + active: { type: 'boolean' }, + }, + }); + + it('should unwrap top-level schema object', () => { + const data = { + id: '550e8400-e29b-4d4e-a7d4-426614174000', + name: 'John Doe', + email: 'john@example.com', + age: 30, + active: true, + }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + }); + + describe('Top-level schema with nested objects', () => { + const Schema = schema({ + type: 'object', + properties: { + user: { + type: 'object', + properties: { + id: { type: 'integer', format: 'int32' }, + name: { type: 'string' }, + }, + }, + settings: { + type: 'object', + properties: { + theme: { type: 'string' }, + notifications: { type: 'boolean' }, + }, + }, + }, + }); + + it('should handle nested objects in top-level schema', () => { + const data = { + user: { + id: 123, + name: 'Jane Doe', + }, + settings: { + theme: 'dark', + notifications: true, + }, + }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + }); + + describe('Top-level schema with $ref', () => { + const Schema = schema( + { + type: 'object', + properties: { + user: { $ref: '#/User' }, + product: { $ref: '#/Product' }, + }, + }, + { + schemas: { + User: { + type: 'object', + properties: { + id: { type: 'integer', format: 'int32' }, + name: { type: 'string' }, + }, + }, + Product: { + type: 'object', + properties: { + sku: { type: 'string' }, + price: { type: 'number', format: 'double' }, + }, + }, + }, + }, + ); + + it('should handle $ref in top-level schema object', () => { + const data = { + user: { + id: 1, + name: 'Alice', + }, + product: { + sku: 'PROD-001', + price: 29.99, + }, + }; + expect(Schema.read(Schema.write(data))).toEqual(data); + }); + }); +}); diff --git a/tests/integration/openapi.ts b/tests/integration/openapi.ts new file mode 100644 index 0000000..a30f2aa --- /dev/null +++ b/tests/integration/openapi.ts @@ -0,0 +1,40 @@ +import { schema } from '../../src'; +import spec from '../../static/exampleSpec'; + +/* Tests --------------------------------------------------------------------- */ + +describe('OpenAPI spec test', () => { + describe('Parse simple API response', () => { + const Schema = schema(spec.components.schemas.SiteVerificationWebResourceGettokenResponse); + + const response = { + method: 'META', token: 'abc', + }; + + it('should return the response object unchanged', () => { + expect(Schema.read(Schema.write(response))).toEqual(response); + }); + }); + + describe('Parse API response with local $ref', () => { + const Schema = schema(spec.components.schemas.SiteVerificationWebResourceListResponse, { schemas: spec }); + + const response = { + items: [{ + id: 'compactr', + owners: [ + 'bob', + 'mary', + ], + site: { + identifier: 'compactr.js.org', + type: 'SITE', + }, + }], + }; + + it('should return the response object unchanged', () => { + expect(Schema.read(Schema.write(response))).toEqual(response); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..0306e9a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "node", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "allowUmdGlobalAccess": true, + "noImplicitAny": false, + "removeComments": true, + "preserveConstEnums": true, + "sourceMap": false, + "allowJs": true, + "target": "esnext", + "outDir": "dist" + }, + "include": [ + "./src", + "./types.d.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/types.d.ts b/types.d.ts new file mode 100644 index 0000000..62b3526 --- /dev/null +++ b/types.d.ts @@ -0,0 +1,59 @@ +/** + * Type definitions for Compactr + * Schema based serialization made easy + */ +declare module 'compactr' { + export const schema: (schema: SchemaDefinition, options?: SchemaOptions) => SchemaInstance; + + export interface SchemaInstance { + /** + * Encodes data according to the schema + * @param data The data to be encoded + * @param options The options for the encoding + * @returns The encoded buffer + */ + write(data: any, options?: WriteOptions): Buffer + + /** + * Returns the byte sizes of a data object, for insight or troubleshooting + * @param data The data to extract size information of + * @returns The detailed sizes information + */ + sizes(data: any): Record + + /** + * Reads data from a buffer and decodes it according to the schema + * @param buffer The buffer to decode + * @param options The options for the decoding + * @returns The decoded data + */ + read(buffer: Buffer | number[]): any + } + + export interface SchemaFieldDefinition { + type?: 'boolean' | 'integer' | 'number' | 'string' | 'array' | 'object' + format?: 'int32' | 'int64' | 'float' | 'double' | 'uuid' | 'ipv4' | 'ipv6' | 'date' | 'date-time' | 'binary' + nullable?: boolean + count?: number + size?: number + schema?: SchemaDefinition + properties?: SchemaDefinition + items?: SchemaFieldDefinition + oneOf?: SchemaFieldDefinition[] + anyOf?: SchemaFieldDefinition[] + $ref?: string + } + + export interface SchemaDefinition { + [key: string]: SchemaFieldDefinition + } + + export interface SchemaOptions { + schemas?: { [key: string]: SchemaFieldDefinition } + } + + export interface WriteOptions { + coerse?: boolean + validate?: boolean + } +}