schema.spec.ts 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. import { describe, expect, expectTypeOf, it } from 'vitest'
  2. import {
  3. JsonSchemaError,
  4. parameterSchemaSpecToJsonSchema,
  5. valueSchemaSpecToJsonSchema,
  6. type InferArgs,
  7. type InferValue,
  8. type ParameterSchemaSpec,
  9. type ValueSchemaSpec,
  10. } from '../src/index.ts'
  11. import type { JsonValue } from '@deepseek-ai/dsh-util-values'
  12. describe('the unified author schema DSL', () => {
  13. it('compiles every value root and the author-only json node', () => {
  14. expect(valueSchemaSpecToJsonSchema({ type: 'string', enum: ['a', 'b'], const: 'a' }))
  15. .toEqual({ type: 'string', enum: ['a', 'b'], const: 'a' })
  16. expect(valueSchemaSpecToJsonSchema({ type: 'number' })).toEqual({ type: 'number' })
  17. expect(valueSchemaSpecToJsonSchema({ type: 'integer' })).toEqual({ type: 'integer' })
  18. expect(valueSchemaSpecToJsonSchema({ type: 'boolean' })).toEqual({ type: 'boolean' })
  19. expect(valueSchemaSpecToJsonSchema({ type: 'null' })).toEqual({ type: 'null' })
  20. expect(valueSchemaSpecToJsonSchema({ type: 'array', items: { type: 'json' } }))
  21. .toEqual({ type: 'array', items: {} })
  22. expect(valueSchemaSpecToJsonSchema({ type: 'object', additionalProperties: false, properties: {} }))
  23. .toEqual({ type: 'object', additionalProperties: false, properties: {} })
  24. expect(valueSchemaSpecToJsonSchema({
  25. type: 'json',
  26. description: 'anything',
  27. title: 'Any JSON',
  28. default: null,
  29. examples: [{ nested: true }],
  30. })).toEqual({ description: 'anything', title: 'Any JSON', default: null, examples: [{ nested: true }] })
  31. expect(valueSchemaSpecToJsonSchema({ oneOf: [{ type: 'string' }, { type: 'null' }] }))
  32. .toEqual({ oneOf: [{ type: 'string' }, { type: 'null' }] })
  33. })
  34. it('keeps the implicit parameter root open while preserving explicit object openness', () => {
  35. expect(parameterSchemaSpecToJsonSchema({
  36. closed: {
  37. type: 'object',
  38. additionalProperties: false,
  39. required: true,
  40. properties: { id: { type: 'integer', required: true } },
  41. },
  42. open: { type: 'object', additionalProperties: true },
  43. })).toEqual({
  44. type: 'object',
  45. properties: {
  46. closed: {
  47. type: 'object',
  48. additionalProperties: false,
  49. properties: { id: { type: 'integer' } },
  50. required: ['id'],
  51. },
  52. open: { type: 'object', additionalProperties: true },
  53. },
  54. required: ['closed'],
  55. })
  56. })
  57. it('rejects runtime-forged author forms rather than compiling them lossily', () => {
  58. for (const schema of [
  59. { type: 'object' },
  60. { oneOf: [{ type: 'string' }] },
  61. { type: 'number', enum: ['1'] },
  62. { type: 'string', enum: ['a'], const: 'b' },
  63. { type: 'integer', const: 1.5 },
  64. { type: 'json', default: undefined },
  65. { type: 'array', items: { type: 'string', required: true } },
  66. { type: 'array', items: 42 },
  67. { type: 'string', extra: true },
  68. { type: 'string', oneOf: [{ type: 'string' }, { type: 'null' }] },
  69. { oneOf: 'not-an-array' },
  70. { type: 'string', enum: 'a' },
  71. {},
  72. null,
  73. ]) {
  74. expect(() => valueSchemaSpecToJsonSchema(schema as ValueSchemaSpec), JSON.stringify(schema)).toThrow(JsonSchemaError)
  75. }
  76. expect(() => parameterSchemaSpecToJsonSchema({
  77. value: { type: 'string', required: false },
  78. } as unknown as ParameterSchemaSpec)).toThrow(JsonSchemaError)
  79. expect(() => parameterSchemaSpecToJsonSchema(null as unknown as ParameterSchemaSpec)).toThrow(JsonSchemaError)
  80. expect(() => parameterSchemaSpecToJsonSchema({ bad: 42 } as unknown as ParameterSchemaSpec)).toThrow(JsonSchemaError)
  81. const symbolKey = Symbol('hidden')
  82. expect(() => parameterSchemaSpecToJsonSchema({
  83. value: { type: 'string' },
  84. [symbolKey]: { type: 'number' },
  85. } as unknown as ParameterSchemaSpec)).toThrow(JsonSchemaError)
  86. const hiddenKey = Object.defineProperty({ value: { type: 'string' } }, 'hidden', {
  87. value: { type: 'number' },
  88. })
  89. expect(() => parameterSchemaSpecToJsonSchema(hiddenKey as ParameterSchemaSpec)).toThrow(JsonSchemaError)
  90. const sparseOneOf = new Array<ValueSchemaSpec>(2)
  91. sparseOneOf[0] = { type: 'string' }
  92. expect(() => valueSchemaSpecToJsonSchema({ oneOf: sparseOneOf } as unknown as ValueSchemaSpec)).toThrow(JsonSchemaError)
  93. const decoratedEnum = Object.assign(['a'], { hidden: true })
  94. expect(() => valueSchemaSpecToJsonSchema({
  95. type: 'string',
  96. enum: decoratedEnum,
  97. })).toThrow(JsonSchemaError)
  98. })
  99. it('rejects cyclic author schemas', () => {
  100. const schema: Record<string, unknown> = { type: 'array' }
  101. schema.items = schema
  102. expect(() => valueSchemaSpecToJsonSchema(schema as unknown as ValueSchemaSpec)).toThrow(/circular/)
  103. const properties: Record<string, unknown> = {}
  104. properties.self = { type: 'object', additionalProperties: true, properties }
  105. expect(() => parameterSchemaSpecToJsonSchema(properties as ParameterSchemaSpec)).toThrow(/circular/)
  106. })
  107. it('compiles deeply nested author unions without using the JavaScript call stack', () => {
  108. const depth = 5_000
  109. let spec: unknown = { type: 'string' }
  110. for (let index = 0; index < depth; index++) spec = { oneOf: [spec, { type: 'null' }] }
  111. const compiled = valueSchemaSpecToJsonSchema(spec as ValueSchemaSpec)
  112. let cursor = compiled
  113. let layers = 0
  114. while (cursor.oneOf !== undefined) {
  115. cursor = cursor.oneOf[0]!
  116. layers++
  117. }
  118. expect(layers).toBe(depth)
  119. expect(cursor).toEqual({ type: 'string' })
  120. })
  121. it('preserves a property literally named __proto__ as schema data', () => {
  122. const properties = Object.create(null) as ParameterSchemaSpec
  123. properties.__proto__ = { type: 'string', required: true }
  124. const schema = parameterSchemaSpecToJsonSchema(properties)
  125. expect(Object.hasOwn(schema.properties, '__proto__')).toBe(true)
  126. expect(schema.properties.__proto__).toEqual({ type: 'string' })
  127. expect(schema.required).toEqual(['__proto__'])
  128. })
  129. it('infers scalar literals, arrays, objects, json, and exact-one unions', () => {
  130. expectTypeOf<InferValue<{ type: 'string'; enum: readonly ['a', 'b'] }>>().toEqualTypeOf<'a' | 'b'>()
  131. expectTypeOf<InferValue<{ type: 'number'; const: 1 }>>().toEqualTypeOf<1>()
  132. expectTypeOf<InferValue<{ type: 'integer' }>>().toEqualTypeOf<number>()
  133. expectTypeOf<InferValue<{ type: 'boolean'; enum: readonly [true] }>>().toEqualTypeOf<true>()
  134. expectTypeOf<InferValue<{ type: 'null' }>>().toEqualTypeOf<null>()
  135. expectTypeOf<InferValue<{ type: 'array'; items: { type: 'string' } }>>().toEqualTypeOf<string[]>()
  136. expectTypeOf<InferValue<{ type: 'array' }>>().toEqualTypeOf<JsonValue[]>()
  137. expectTypeOf<InferValue<{ type: 'json' }>>().toEqualTypeOf<JsonValue>()
  138. expectTypeOf<InferValue<{ oneOf: readonly [{ type: 'string' }, { type: 'null' }] }>>()
  139. .toEqualTypeOf<string | null>()
  140. expectTypeOf<InferValue<{
  141. type: 'object'
  142. additionalProperties: false
  143. properties: { id: { type: 'integer'; required: true }; label: { type: 'string' } }
  144. }>>().toEqualTypeOf<{ id: number; label?: string }>()
  145. expectTypeOf<InferValue<{
  146. type: 'object'
  147. additionalProperties: true
  148. properties: { id: { type: 'integer'; required: true } }
  149. }>>().toEqualTypeOf<{ id: number } & Record<string, JsonValue>>()
  150. })
  151. it('bounds inference for deeply nested author schemas', () => {
  152. type Repeat<Count extends number, Result extends unknown[] = []> =
  153. Result['length'] extends Count ? Result : Repeat<Count, [unknown, ...Result]>
  154. type DeepArraySchema<Levels extends unknown[]> =
  155. Levels extends [unknown, ...infer Rest]
  156. ? { type: 'array'; items: DeepArraySchema<Rest> }
  157. : { type: 'string' }
  158. type PeelArrays<Value, Levels extends unknown[]> =
  159. Levels extends [unknown, ...infer Rest]
  160. ? Value extends (infer Item)[] ? PeelArrays<Item, Rest> : never
  161. : Value
  162. type DeepValue = InferValue<DeepArraySchema<Repeat<50>>>
  163. expectTypeOf<PeelArrays<DeepValue, Repeat<16>>>().toEqualTypeOf<JsonValue>()
  164. })
  165. it('infers required and optional parameter keys', () => {
  166. expectTypeOf<InferArgs<{
  167. path: { type: 'string'; required: true }
  168. offset: { type: 'integer' }
  169. data: { type: 'json' }
  170. }>>().toEqualTypeOf<{ path: string; offset?: number; data?: JsonValue }>()
  171. })
  172. it('makes invalid author forms compile-time errors', () => {
  173. const symbolKey = Symbol('parameter')
  174. const invalidObjects = {
  175. // @ts-expect-error explicit object schemas require an openness decision
  176. object: { type: 'object' } satisfies ValueSchemaSpec,
  177. // @ts-expect-error oneOf requires at least two branches
  178. oneOf: { oneOf: [{ type: 'string' }] } satisfies ValueSchemaSpec,
  179. // @ts-expect-error scalar enum values must match the node type
  180. enum: { type: 'number', enum: ['1'] } satisfies ValueSchemaSpec,
  181. // @ts-expect-error parameter requiredness is true-or-absent
  182. required: { value: { type: 'string', required: false } } satisfies ParameterSchemaSpec,
  183. // @ts-expect-error parameter maps accept string keys only
  184. symbol: { [symbolKey]: { type: 'string' } } satisfies ParameterSchemaSpec,
  185. }
  186. expect(Object.keys(invalidObjects)).toHaveLength(5)
  187. })
  188. })