@atproto/lexicon
Version:
atproto Lexicon schema language library
1,243 lines (1,178 loc) • 39.1 kB
text/typescript
import { CID } from 'multiformats/cid'
import { LexiconDoc, Lexicons, parseLexiconDoc } from '../src/index'
import LexiconDocs from './_scaffolds/lexicons'
describe('Lexicons collection', () => {
const lex = new Lexicons(LexiconDocs)
it('Adds schemas', () => {
expect(() => lex.add(LexiconDocs[0])).toThrow()
})
it('Correctly references all definitions', () => {
expect(lex.getDef('com.example.kitchenSink')).toEqual(
LexiconDocs[0].defs.main,
)
expect(lex.getDef('lex:com.example.kitchenSink')).toEqual(
LexiconDocs[0].defs.main,
)
expect(lex.getDef('com.example.kitchenSink#main')).toEqual(
LexiconDocs[0].defs.main,
)
expect(lex.getDef('lex:com.example.kitchenSink#main')).toEqual(
LexiconDocs[0].defs.main,
)
expect(lex.getDef('com.example.kitchenSink#object')).toEqual(
LexiconDocs[0].defs.object,
)
expect(lex.getDef('lex:com.example.kitchenSink#object')).toEqual(
LexiconDocs[0].defs.object,
)
})
})
describe('General validation', () => {
const lex = new Lexicons(LexiconDocs)
it('Validates records correctly', () => {
{
const res = lex.validate('com.example.kitchenSink', {
$type: 'com.example.kitchenSink',
object: {
object: { boolean: true },
array: ['one', 'two'],
boolean: true,
integer: 123,
string: 'string',
},
array: ['one', 'two'],
boolean: true,
integer: 123,
string: 'string',
datetime: new Date().toISOString(),
atUri: 'at://did:web:example.com/com.example.test/self',
did: 'did:web:example.com',
cid: 'bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a',
bytes: new Uint8Array([0, 1, 2, 3]),
cidLink: CID.parse(
'bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a',
),
})
expect(res.success).toBe(true)
}
{
const res = lex.validate('com.example.kitchenSink', {})
expect(res.success).toBe(false)
if (res.success) throw new Error('Asserted')
expect(res.error?.message).toBe('Record must have the property "object"')
}
})
it('Validates objects correctly', () => {
{
const res = lex.validate('com.example.kitchenSink#object', {
object: { boolean: true },
array: ['one', 'two'],
boolean: true,
integer: 123,
string: 'string',
})
expect(res.success).toBe(true)
}
{
const res = lex.validate('com.example.kitchenSink#object', {})
expect(res.success).toBe(false)
if (res.success) throw new Error('Asserted')
expect(res.error?.message).toBe('Object must have the property "object"')
}
})
it('fails when a required property is missing', () => {
const schema = {
lexicon: 1,
id: 'com.example.kitchenSink',
defs: {
test: {
type: 'object',
required: ['foo'],
properties: {},
},
},
}
expect(() => {
parseLexiconDoc(schema)
}).toThrow('Required field \\"foo\\" not defined')
})
it('fails when unknown fields are present', () => {
const schema = {
lexicon: 1,
id: 'com.example.unknownFields',
defs: {
test: {
type: 'object',
foo: 3,
},
},
}
expect(() => {
parseLexiconDoc(schema)
}).toThrow("Unrecognized key(s) in object: 'foo'")
})
it('fails lexicon parsing when uri is invalid', () => {
const schema: LexiconDoc = {
lexicon: 1,
id: 'com.example.invalidUri',
defs: {
main: {
type: 'object',
properties: {
test: { type: 'ref', ref: 'com.example.invalid#test#test' },
},
},
},
}
expect(() => {
new Lexicons([schema])
}).toThrow('Uri can only have one hash segment')
})
it('fails validation when ref uri has multiple hash segments', () => {
const schema: LexiconDoc = {
lexicon: 1,
id: 'com.example.invalidUri',
defs: {
main: {
type: 'object',
properties: {
test: { type: 'integer' },
},
},
object: {
type: 'object',
required: ['test'],
properties: {
test: {
type: 'union',
refs: ['com.example.invalidUri'],
},
},
},
},
}
const lexicons = new Lexicons([schema])
expect(() => {
lexicons.validate('com.example.invalidUri#object', {
test: {
$type: 'com.example.invalidUri#main#main',
test: 123,
},
})
}).toThrow('Uri can only have one hash segment')
})
it('union handles both implicit and explicit #main', () => {
const schemas: LexiconDoc[] = [
{
lexicon: 1,
id: 'com.example.implicitMain',
defs: {
main: {
type: 'object',
required: ['test'],
properties: {
test: { type: 'string' },
},
},
},
},
{
lexicon: 1,
id: 'com.example.testImplicitMain',
defs: {
main: {
type: 'object',
required: ['union'],
properties: {
union: {
type: 'union',
refs: ['com.example.implicitMain'],
},
},
},
},
},
{
lexicon: 1,
id: 'com.example.testExplicitMain',
defs: {
main: {
type: 'object',
required: ['union'],
properties: {
union: {
type: 'union',
refs: ['com.example.implicitMain#main'],
},
},
},
},
},
]
const lexicon = new Lexicons(schemas)
let result = lexicon.validate('com.example.testImplicitMain', {
union: {
$type: 'com.example.implicitMain',
test: 123,
},
})
expect(result.success).toBeFalsy()
expect(result['error']?.message).toBe('Object/union/test must be a string')
result = lexicon.validate('com.example.testImplicitMain', {
union: {
$type: 'com.example.implicitMain#main',
test: 123,
},
})
expect(result.success).toBeFalsy()
expect(result['error']?.message).toBe('Object/union/test must be a string')
result = lexicon.validate('com.example.testExplicitMain', {
union: {
$type: 'com.example.implicitMain',
test: 123,
},
})
expect(result.success).toBeFalsy()
expect(result['error']?.message).toBe('Object/union/test must be a string')
result = lexicon.validate('com.example.testExplicitMain', {
union: {
$type: 'com.example.implicitMain#main',
test: 123,
},
})
expect(result.success).toBeFalsy()
expect(result['error']?.message).toBe('Object/union/test must be a string')
})
})
describe('Record validation', () => {
const lex = new Lexicons(LexiconDocs)
const passingSink = {
$type: 'com.example.kitchenSink',
object: {
object: { boolean: true },
array: ['one', 'two'],
boolean: true,
integer: 123,
string: 'string',
},
array: ['one', 'two'],
boolean: true,
integer: 123,
string: 'string',
bytes: new Uint8Array([0, 1, 2, 3]),
cidLink: CID.parse(
'bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a',
),
}
it('Passes valid schemas', () => {
lex.assertValidRecord('com.example.kitchenSink', passingSink)
})
it('Fails invalid input types', () => {
expect(() =>
lex.assertValidRecord('com.example.kitchenSink', undefined),
).toThrow('Record must be an object')
expect(() =>
lex.assertValidRecord('com.example.kitchenSink', 1234),
).toThrow('Record must be an object')
expect(() =>
lex.assertValidRecord('com.example.kitchenSink', 'string'),
).toThrow('Record must be an object')
})
it('Fails incorrect $type', () => {
expect(() => lex.assertValidRecord('com.example.kitchenSink', {})).toThrow(
'Record/$type must be a string',
)
expect(() =>
lex.assertValidRecord('com.example.kitchenSink', { $type: 'foo' }),
).toThrow('Invalid $type: must be lex:com.example.kitchenSink, got foo')
})
it('Fails missing required', () => {
expect(() =>
lex.assertValidRecord('com.example.kitchenSink', {
$type: 'com.example.kitchenSink',
array: ['one', 'two'],
boolean: true,
integer: 123,
string: 'string',
datetime: new Date().toISOString(),
atUri: 'at://did:web:example.com/com.example.test/self',
did: 'did:web:example.com',
cid: 'bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a',
bytes: new Uint8Array([0, 1, 2, 3]),
cidLink: CID.parse(
'bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a',
),
}),
).toThrow('Record must have the property "object"')
expect(() =>
lex.assertValidRecord('com.example.kitchenSink', {
...passingSink,
object: undefined,
}),
).toThrow('Record must have the property "object"')
})
it('Fails incorrect types', () => {
expect(() =>
lex.assertValidRecord('com.example.kitchenSink', {
...passingSink,
object: {
...passingSink.object,
object: { boolean: '1234' },
},
}),
).toThrow('Record/object/object/boolean must be a boolean')
expect(() =>
lex.assertValidRecord('com.example.kitchenSink', {
...passingSink,
object: true,
}),
).toThrow('Record/object must be an object')
expect(() =>
lex.assertValidRecord('com.example.kitchenSink', {
...passingSink,
array: 1234,
}),
).toThrow('Record/array must be an array')
expect(() =>
lex.assertValidRecord('com.example.kitchenSink', {
...passingSink,
integer: true,
}),
).toThrow('Record/integer must be an integer')
expect(() =>
lex.assertValidRecord('com.example.kitchenSink', {
...passingSink,
string: {},
}),
).toThrow('Record/string must be a string')
expect(() =>
lex.assertValidRecord('com.example.kitchenSink', {
...passingSink,
bytes: 1234,
}),
).toThrow('Record/bytes must be a byte array')
expect(() =>
lex.assertValidRecord('com.example.kitchenSink', {
...passingSink,
cidLink: 'bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a',
}),
).toThrow('Record/cidLink must be a CID')
})
it('Handles optional properties correctly', () => {
lex.assertValidRecord('com.example.optional', {
$type: 'com.example.optional',
})
})
it('Handles default properties correctly', () => {
const result = lex.assertValidRecord('com.example.default', {
$type: 'com.example.default',
object: {},
})
expect(result).toEqual({
$type: 'com.example.default',
boolean: false,
integer: 0,
string: '',
object: {
boolean: true,
integer: 1,
string: 'x',
},
})
expect(result).not.toHaveProperty('datetime')
})
it('Handles unions correctly', () => {
lex.assertValidRecord('com.example.union', {
$type: 'com.example.union',
unionOpen: {
$type: 'com.example.kitchenSink#object',
object: { boolean: true },
array: ['one', 'two'],
boolean: true,
integer: 123,
string: 'string',
},
unionClosed: {
$type: 'com.example.kitchenSink#subobject',
boolean: true,
},
})
lex.assertValidRecord('com.example.union', {
$type: 'com.example.union',
unionOpen: {
$type: 'com.example.other',
},
unionClosed: {
$type: 'com.example.kitchenSink#subobject',
boolean: true,
},
})
expect(() =>
lex.assertValidRecord('com.example.union', {
$type: 'com.example.union',
unionOpen: {},
unionClosed: {},
}),
).toThrow(
'Record/unionOpen must be an object which includes the "$type" property',
)
expect(() =>
lex.assertValidRecord('com.example.union', {
$type: 'com.example.union',
unionOpen: {
$type: 'com.example.other',
},
unionClosed: {
$type: 'com.example.other',
boolean: true,
},
}),
).toThrow(
'Record/unionClosed $type must be one of lex:com.example.kitchenSink#object, lex:com.example.kitchenSink#subobject',
)
})
it('Handles unknowns correctly', () => {
lex.assertValidRecord('com.example.unknown', {
$type: 'com.example.unknown',
unknown: { foo: 'bar' },
})
expect(() =>
lex.assertValidRecord('com.example.unknown', {
$type: 'com.example.unknown',
}),
).toThrow('Record must have the property "unknown"')
})
it('Applies array length constraints', () => {
lex.assertValidRecord('com.example.arrayLength', {
$type: 'com.example.arrayLength',
array: [1, 2, 3],
})
expect(() =>
lex.assertValidRecord('com.example.arrayLength', {
$type: 'com.example.arrayLength',
array: [1],
}),
).toThrow('Record/array must not have fewer than 2 elements')
expect(() =>
lex.assertValidRecord('com.example.arrayLength', {
$type: 'com.example.arrayLength',
array: [1, 2, 3, 4, 5],
}),
).toThrow('Record/array must not have more than 4 elements')
})
it('Applies array item constraints', () => {
expect(() =>
lex.assertValidRecord('com.example.arrayLength', {
$type: 'com.example.arrayLength',
array: [1, '2', 3],
}),
).toThrow('Record/array/1 must be an integer')
expect(() =>
lex.assertValidRecord('com.example.arrayLength', {
$type: 'com.example.arrayLength',
array: [1, undefined, 3],
}),
).toThrow('Record/array/1 must be an integer')
})
it('Applies boolean const constraint', () => {
lex.assertValidRecord('com.example.boolConst', {
$type: 'com.example.boolConst',
boolean: false,
})
expect(() =>
lex.assertValidRecord('com.example.boolConst', {
$type: 'com.example.boolConst',
boolean: true,
}),
).toThrow('Record/boolean must be false')
})
it('Applies integer range constraint', () => {
lex.assertValidRecord('com.example.integerRange', {
$type: 'com.example.integerRange',
integer: 2,
})
expect(() =>
lex.assertValidRecord('com.example.integerRange', {
$type: 'com.example.integerRange',
integer: 1,
}),
).toThrow('Record/integer can not be less than 2')
expect(() =>
lex.assertValidRecord('com.example.integerRange', {
$type: 'com.example.integerRange',
integer: 5,
}),
).toThrow('Record/integer can not be greater than 4')
})
it('Applies integer enum constraint', () => {
lex.assertValidRecord('com.example.integerEnum', {
$type: 'com.example.integerEnum',
integer: 2,
})
expect(() =>
lex.assertValidRecord('com.example.integerEnum', {
$type: 'com.example.integerEnum',
integer: 0,
}),
).toThrow('Record/integer must be one of (1|2)')
})
it('Applies integer const constraint', () => {
lex.assertValidRecord('com.example.integerConst', {
$type: 'com.example.integerConst',
integer: 0,
})
expect(() =>
lex.assertValidRecord('com.example.integerConst', {
$type: 'com.example.integerConst',
integer: 1,
}),
).toThrow('Record/integer must be 0')
})
it('Applies integer whole-number constraint', () => {
expect(() =>
lex.assertValidRecord('com.example.integerRange', {
$type: 'com.example.integerRange',
integer: 2.5,
}),
).toThrow('Record/integer must be an integer')
})
it('Applies string length constraint', () => {
// Shorter than two UTF8 characters
expect(() =>
lex.assertValidRecord('com.example.stringLength', {
$type: 'com.example.stringLength',
string: '',
}),
).toThrow('Record/string must not be shorter than 2 characters')
expect(() =>
lex.assertValidRecord('com.example.stringLength', {
$type: 'com.example.stringLength',
string: 'a',
}),
).toThrow('Record/string must not be shorter than 2 characters')
// Two to four UTF8 characters
lex.assertValidRecord('com.example.stringLength', {
$type: 'com.example.stringLength',
string: 'ab',
})
lex.assertValidRecord('com.example.stringLength', {
$type: 'com.example.stringLength',
string: '\u0301', // Combining acute accent (2 bytes)
})
lex.assertValidRecord('com.example.stringLength', {
$type: 'com.example.stringLength',
string: 'a\u0301', // 'a' + combining acute accent (1 + 2 bytes = 3 bytes)
})
lex.assertValidRecord('com.example.stringLength', {
$type: 'com.example.stringLength',
string: 'aé', // 'a' (1 byte) + 'é' (2 bytes) = 3 bytes
})
lex.assertValidRecord('com.example.stringLength', {
$type: 'com.example.stringLength',
string: 'abc',
})
lex.assertValidRecord('com.example.stringLength', {
$type: 'com.example.stringLength',
string: '一', // CJK character (3 bytes)
})
lex.assertValidRecord('com.example.stringLength', {
$type: 'com.example.stringLength',
string: '\uD83D', // Unpaired high surrogate (3 bytes)
})
lex.assertValidRecord('com.example.stringLength', {
$type: 'com.example.stringLength',
string: 'abcd',
})
lex.assertValidRecord('com.example.stringLength', {
$type: 'com.example.stringLength',
string: 'éé', // 'é' + 'é' (2 + 2 bytes = 4 bytes)
})
lex.assertValidRecord('com.example.stringLength', {
$type: 'com.example.stringLength',
string: 'aaé', // 1 + 1 + 2 = 4 bytes
})
lex.assertValidRecord('com.example.stringLength', {
$type: 'com.example.stringLength',
string: '👋', // 4 bytes
})
expect(() =>
lex.assertValidRecord('com.example.stringLength', {
$type: 'com.example.stringLength',
string: 'abcde',
}),
).toThrow('Record/string must not be longer than 4 characters')
expect(() =>
lex.assertValidRecord('com.example.stringLength', {
$type: 'com.example.stringLength',
string: 'a\u0301\u0301', // 1 + (2 * 2) = 5 bytes
}),
).toThrow('Record/string must not be longer than 4 characters')
expect(() =>
lex.assertValidRecord('com.example.stringLength', {
$type: 'com.example.stringLength',
string: '\uD83D\uD83D', // Two unpaired high surrogates (3 * 2 = 6 bytes)
}),
).toThrow('Record/string must not be longer than 4 characters')
expect(() =>
lex.assertValidRecord('com.example.stringLength', {
$type: 'com.example.stringLength',
string: 'ééé', // 2 + 2 + 2 bytes = 6 bytes
}),
).toThrow('Record/string must not be longer than 4 characters')
expect(() =>
lex.assertValidRecord('com.example.stringLength', {
$type: 'com.example.stringLength',
string: '👋a', // 4 + 1 bytes = 5 bytes
}),
).toThrow('Record/string must not be longer than 4 characters')
expect(() =>
lex.assertValidRecord('com.example.stringLength', {
$type: 'com.example.stringLength',
string: '👨👨', // 4 + 4 = 8 bytes
}),
).toThrow('Record/string must not be longer than 4 characters')
expect(() =>
lex.assertValidRecord('com.example.stringLength', {
$type: 'com.example.stringLength',
string: '👨👩👧👧', // 4 emojis × 4 bytes + 3 ZWJs × 3 bytes = 25 bytes
}),
).toThrow('Record/string must not be longer than 4 characters')
})
it('Applies string length constraint (no minLength)', () => {
// Shorter than two UTF8 characters
lex.assertValidRecord('com.example.stringLengthNoMinLength', {
$type: 'com.example.stringLengthNoMinLength',
string: '',
})
lex.assertValidRecord('com.example.stringLengthNoMinLength', {
$type: 'com.example.stringLengthNoMinLength',
string: 'a',
})
// Two to four UTF8 characters
lex.assertValidRecord('com.example.stringLengthNoMinLength', {
$type: 'com.example.stringLengthNoMinLength',
string: 'ab',
})
lex.assertValidRecord('com.example.stringLengthNoMinLength', {
$type: 'com.example.stringLengthNoMinLength',
string: '\u0301', // Combining acute accent (2 bytes)
})
lex.assertValidRecord('com.example.stringLengthNoMinLength', {
$type: 'com.example.stringLengthNoMinLength',
string: 'a\u0301', // 'a' + combining acute accent (1 + 2 bytes = 3 bytes)
})
lex.assertValidRecord('com.example.stringLengthNoMinLength', {
$type: 'com.example.stringLengthNoMinLength',
string: 'aé', // 'a' (1 byte) + 'é' (2 bytes) = 3 bytes
})
lex.assertValidRecord('com.example.stringLengthNoMinLength', {
$type: 'com.example.stringLengthNoMinLength',
string: 'abc',
})
lex.assertValidRecord('com.example.stringLengthNoMinLength', {
$type: 'com.example.stringLengthNoMinLength',
string: '一', // CJK character (3 bytes)
})
lex.assertValidRecord('com.example.stringLengthNoMinLength', {
$type: 'com.example.stringLengthNoMinLength',
string: '\uD83D', // Unpaired high surrogate (3 bytes)
})
lex.assertValidRecord('com.example.stringLengthNoMinLength', {
$type: 'com.example.stringLengthNoMinLength',
string: 'abcd',
})
lex.assertValidRecord('com.example.stringLengthNoMinLength', {
$type: 'com.example.stringLengthNoMinLength',
string: 'éé', // 'é' + 'é' (2 + 2 bytes = 4 bytes)
})
lex.assertValidRecord('com.example.stringLengthNoMinLength', {
$type: 'com.example.stringLengthNoMinLength',
string: 'aaé', // 1 + 1 + 2 = 4 bytes
})
lex.assertValidRecord('com.example.stringLengthNoMinLength', {
$type: 'com.example.stringLengthNoMinLength',
string: '👋', // 4 bytes
})
expect(() =>
lex.assertValidRecord('com.example.stringLengthNoMinLength', {
$type: 'com.example.stringLengthNoMinLength',
string: 'abcde',
}),
).toThrow('Record/string must not be longer than 4 characters')
expect(() =>
lex.assertValidRecord('com.example.stringLengthNoMinLength', {
$type: 'com.example.stringLengthNoMinLength',
string: 'a\u0301\u0301', // 1 + (2 * 2) = 5 bytes
}),
).toThrow('Record/string must not be longer than 4 characters')
expect(() =>
lex.assertValidRecord('com.example.stringLengthNoMinLength', {
$type: 'com.example.stringLengthNoMinLength',
string: '\uD83D\uD83D', // Two unpaired high surrogates (3 * 2 = 6 bytes)
}),
).toThrow('Record/string must not be longer than 4 characters')
expect(() =>
lex.assertValidRecord('com.example.stringLengthNoMinLength', {
$type: 'com.example.stringLengthNoMinLength',
string: 'ééé', // 2 + 2 + 2 bytes = 6 bytes
}),
).toThrow('Record/string must not be longer than 4 characters')
expect(() =>
lex.assertValidRecord('com.example.stringLengthNoMinLength', {
$type: 'com.example.stringLengthNoMinLength',
string: '👋a', // 4 + 1 bytes = 5 bytes
}),
).toThrow('Record/string must not be longer than 4 characters')
expect(() =>
lex.assertValidRecord('com.example.stringLengthNoMinLength', {
$type: 'com.example.stringLengthNoMinLength',
string: '👨👨', // 4 + 4 = 8 bytes
}),
).toThrow('Record/string must not be longer than 4 characters')
expect(() =>
lex.assertValidRecord('com.example.stringLengthNoMinLength', {
$type: 'com.example.stringLengthNoMinLength',
string: '👨👩👧👧', // 4 emojis × 4 bytes + 3 ZWJs × 3 bytes = 25 bytes
}),
).toThrow('Record/string must not be longer than 4 characters')
})
it('Applies grapheme string length constraint', () => {
// Shorter than two graphemes
expect(() =>
lex.assertValidRecord('com.example.stringLengthGrapheme', {
$type: 'com.example.stringLengthGrapheme',
string: '',
}),
).toThrow('Record/string must not be shorter than 2 graphemes')
expect(() =>
lex.assertValidRecord('com.example.stringLengthGrapheme', {
$type: 'com.example.stringLengthGrapheme',
string: '\u0301\u0301\u0301', // Three combining acute accents
}),
).toThrow('Record/string must not be shorter than 2 graphemes')
expect(() =>
lex.assertValidRecord('com.example.stringLengthGrapheme', {
$type: 'com.example.stringLengthGrapheme',
string: 'a',
}),
).toThrow('Record/string must not be shorter than 2 graphemes')
expect(() =>
lex.assertValidRecord('com.example.stringLengthGrapheme', {
$type: 'com.example.stringLengthGrapheme',
string: 'a\u0301\u0301\u0301\u0301', // 'á́́́' ('a' with four combining acute accents)
}),
).toThrow('Record/string must not be shorter than 2 graphemes')
expect(() =>
lex.assertValidRecord('com.example.stringLengthGrapheme', {
$type: 'com.example.stringLengthGrapheme',
string: '5\uFE0F', // '5️' with emoji presentation
}),
).toThrow('Record/string must not be shorter than 2 graphemes')
expect(() =>
lex.assertValidRecord('com.example.stringLengthGrapheme', {
$type: 'com.example.stringLengthGrapheme',
string: '👨👩👧👧',
}),
).toThrow('Record/string must not be shorter than 2 graphemes')
// Two to four graphemes
lex.assertValidRecord('com.example.stringLengthGrapheme', {
$type: 'com.example.stringLengthGrapheme',
string: 'ab',
})
lex.assertValidRecord('com.example.stringLengthGrapheme', {
$type: 'com.example.stringLengthGrapheme',
string: 'a\u0301b', // 'áb' with combining accent
})
lex.assertValidRecord('com.example.stringLengthGrapheme', {
$type: 'com.example.stringLengthGrapheme',
string: 'a\u0301b\u0301', // 'áb́'
})
lex.assertValidRecord('com.example.stringLengthGrapheme', {
$type: 'com.example.stringLengthGrapheme',
string: '😀😀',
})
lex.assertValidRecord('com.example.stringLengthGrapheme', {
$type: 'com.example.stringLengthGrapheme',
string: '12👨👩👧👧',
})
lex.assertValidRecord('com.example.stringLengthGrapheme', {
$type: 'com.example.stringLengthGrapheme',
string: 'abcd',
})
lex.assertValidRecord('com.example.stringLengthGrapheme', {
$type: 'com.example.stringLengthGrapheme',
string: 'a\u0301b\u0301c\u0301d\u0301', // 'áb́ćd́'
})
// Longer than four graphemes
expect(() =>
lex.assertValidRecord('com.example.stringLengthGrapheme', {
$type: 'com.example.stringLengthGrapheme',
string: 'abcde',
}),
).toThrow('Record/string must not be longer than 4 graphemes')
expect(() =>
lex.assertValidRecord('com.example.stringLengthGrapheme', {
$type: 'com.example.stringLengthGrapheme',
string: 'a\u0301b\u0301c\u0301d\u0301e\u0301', // 'áb́ćd́é'
}),
).toThrow('Record/string must not be longer than 4 graphemes')
expect(() =>
lex.assertValidRecord('com.example.stringLengthGrapheme', {
$type: 'com.example.stringLengthGrapheme',
string: '😀😀😀😀😀',
}),
).toThrow('Record/string must not be longer than 4 graphemes')
expect(() =>
lex.assertValidRecord('com.example.stringLengthGrapheme', {
$type: 'com.example.stringLengthGrapheme',
string: 'ab😀de',
}),
).toThrow('Record/string must not be longer than 4 graphemes')
})
it('Applies string enum constraint', () => {
lex.assertValidRecord('com.example.stringEnum', {
$type: 'com.example.stringEnum',
string: 'a',
})
expect(() =>
lex.assertValidRecord('com.example.stringEnum', {
$type: 'com.example.stringEnum',
string: 'c',
}),
).toThrow('Record/string must be one of (a|b)')
})
it('Applies string const constraint', () => {
lex.assertValidRecord('com.example.stringConst', {
$type: 'com.example.stringConst',
string: 'a',
})
expect(() =>
lex.assertValidRecord('com.example.stringConst', {
$type: 'com.example.stringConst',
string: 'b',
}),
).toThrow('Record/string must be a')
})
it('Applies datetime formatting constraint', () => {
for (const datetime of [
'2022-12-12T00:50:36.809Z',
'2022-12-12T00:50:36Z',
'2022-12-12T00:50:36.8Z',
'2022-12-12T00:50:36.80Z',
'2022-12-12T00:50:36+00:00',
'2022-12-12T00:50:36.8+00:00',
'2022-12-11T19:50:36-05:00',
'2022-12-11T19:50:36.8-05:00',
'2022-12-11T19:50:36.80-05:00',
'2022-12-11T19:50:36.809-05:00',
]) {
lex.assertValidRecord('com.example.datetime', {
$type: 'com.example.datetime',
datetime,
})
}
expect(() =>
lex.assertValidRecord('com.example.datetime', {
$type: 'com.example.datetime',
datetime: 'bad date',
}),
).toThrow(
'Record/datetime must be an valid atproto datetime (both RFC-3339 and ISO-8601)',
)
})
it('Applies uri formatting constraint', () => {
for (const uri of [
'https://example.com',
'https://example.com/with/path',
'https://example.com/with/path?and=query',
'at://bsky.social',
'did:example:test',
]) {
lex.assertValidRecord('com.example.uri', {
$type: 'com.example.uri',
uri,
})
}
expect(() =>
lex.assertValidRecord('com.example.uri', {
$type: 'com.example.uri',
uri: 'not a uri',
}),
).toThrow('Record/uri must be a uri')
})
it('Applies at-uri formatting constraint', () => {
lex.assertValidRecord('com.example.atUri', {
$type: 'com.example.atUri',
atUri: 'at://did:web:example.com/com.example.test/self',
})
expect(() =>
lex.assertValidRecord('com.example.atUri', {
$type: 'com.example.atUri',
atUri: 'http://not-atproto.com',
}),
).toThrow('Record/atUri must be a valid at-uri')
})
it('Applies did formatting constraint', () => {
lex.assertValidRecord('com.example.did', {
$type: 'com.example.did',
did: 'did:web:example.com',
})
lex.assertValidRecord('com.example.did', {
$type: 'com.example.did',
did: 'did:plc:12345678abcdefghijklmnop',
})
expect(() =>
lex.assertValidRecord('com.example.did', {
$type: 'com.example.did',
did: 'bad did',
}),
).toThrow('Record/did must be a valid did')
expect(() =>
lex.assertValidRecord('com.example.did', {
$type: 'com.example.did',
did: 'did:short',
}),
).toThrow('Record/did must be a valid did')
})
it('Applies handle formatting constraint', () => {
lex.assertValidRecord('com.example.handle', {
$type: 'com.example.handle',
handle: 'test.bsky.social',
})
lex.assertValidRecord('com.example.handle', {
$type: 'com.example.handle',
handle: 'bsky.test',
})
expect(() =>
lex.assertValidRecord('com.example.handle', {
$type: 'com.example.handle',
handle: 'bad handle',
}),
).toThrow('Record/handle must be a valid handle')
expect(() =>
lex.assertValidRecord('com.example.handle', {
$type: 'com.example.handle',
handle: '-bad-.test',
}),
).toThrow('Record/handle must be a valid handle')
})
it('Applies at-identifier formatting constraint', () => {
lex.assertValidRecord('com.example.atIdentifier', {
$type: 'com.example.atIdentifier',
atIdentifier: 'bsky.test',
})
lex.assertValidRecord('com.example.atIdentifier', {
$type: 'com.example.atIdentifier',
atIdentifier: 'did:plc:12345678abcdefghijklmnop',
})
expect(() =>
lex.assertValidRecord('com.example.atIdentifier', {
$type: 'com.example.atIdentifier',
atIdentifier: 'bad id',
}),
).toThrow('Record/atIdentifier must be a valid did or a handle')
expect(() =>
lex.assertValidRecord('com.example.atIdentifier', {
$type: 'com.example.atIdentifier',
atIdentifier: '-bad-.test',
}),
).toThrow('Record/atIdentifier must be a valid did or a handle')
})
it('Applies nsid formatting constraint', () => {
lex.assertValidRecord('com.example.nsid', {
$type: 'com.example.nsid',
nsid: 'com.atproto.test',
})
lex.assertValidRecord('com.example.nsid', {
$type: 'com.example.nsid',
nsid: 'app.bsky.nested.test',
})
expect(() =>
lex.assertValidRecord('com.example.nsid', {
$type: 'com.example.nsid',
nsid: 'bad nsid',
}),
).toThrow('Record/nsid must be a valid nsid')
expect(() =>
lex.assertValidRecord('com.example.nsid', {
$type: 'com.example.nsid',
nsid: 'com.bad-.foo',
}),
).toThrow('Record/nsid must be a valid nsid')
})
it('Applies cid formatting constraint', () => {
lex.assertValidRecord('com.example.cid', {
$type: 'com.example.cid',
cid: 'bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a',
})
expect(() =>
lex.assertValidRecord('com.example.cid', {
$type: 'com.example.cid',
cid: 'abapsdofiuwrpoiasdfuaspdfoiu',
}),
).toThrow('Record/cid must be a cid string')
})
it('Applies language formatting constraint', () => {
lex.assertValidRecord('com.example.language', {
$type: 'com.example.language',
language: 'en-US-boont',
})
expect(() =>
lex.assertValidRecord('com.example.language', {
$type: 'com.example.language',
language: 'not-a-language-',
}),
).toThrow('Record/language must be a well-formed BCP 47 language tag')
})
it('Applies bytes length constraints', () => {
lex.assertValidRecord('com.example.byteLength', {
$type: 'com.example.byteLength',
bytes: new Uint8Array([1, 2, 3]),
})
expect(() =>
lex.assertValidRecord('com.example.byteLength', {
$type: 'com.example.byteLength',
bytes: new Uint8Array([1]),
}),
).toThrow('Record/bytes must not be smaller than 2 bytes')
expect(() =>
lex.assertValidRecord('com.example.byteLength', {
$type: 'com.example.byteLength',
bytes: new Uint8Array([1, 2, 3, 4, 5]),
}),
).toThrow('Record/bytes must not be larger than 4 bytes')
})
})
describe('XRPC parameter validation', () => {
const lex = new Lexicons(LexiconDocs)
it('Passes valid parameters', () => {
const queryResult = lex.assertValidXrpcParams('com.example.query', {
boolean: true,
integer: 123,
string: 'string',
array: ['x', 'y'],
})
expect(queryResult).toEqual({
boolean: true,
integer: 123,
string: 'string',
array: ['x', 'y'],
def: 0,
})
const paramResult = lex.assertValidXrpcParams('com.example.procedure', {
boolean: true,
integer: 123,
string: 'string',
array: ['x', 'y'],
def: 1,
})
expect(paramResult).toEqual({
boolean: true,
integer: 123,
string: 'string',
array: ['x', 'y'],
def: 1,
})
})
it('Handles required correctly', () => {
lex.assertValidXrpcParams('com.example.query', {
boolean: true,
integer: 123,
})
expect(() =>
lex.assertValidXrpcParams('com.example.query', {
boolean: true,
}),
).toThrow('Params must have the property "integer"')
expect(() =>
lex.assertValidXrpcParams('com.example.query', {
boolean: true,
integer: undefined,
}),
).toThrow('Params must have the property "integer"')
})
it('Validates parameter types', () => {
expect(() =>
lex.assertValidXrpcParams('com.example.query', {
boolean: 'string',
integer: 123,
string: 'string',
}),
).toThrow('boolean must be a boolean')
expect(() =>
lex.assertValidXrpcParams('com.example.query', {
boolean: true,
float: 123.45,
integer: 123,
string: 'string',
array: 'x',
}),
).toThrow('array must be an array')
})
})
describe('XRPC input validation', () => {
const lex = new Lexicons(LexiconDocs)
it('Passes valid inputs', () => {
lex.assertValidXrpcInput('com.example.procedure', {
object: { boolean: true },
array: ['one', 'two'],
boolean: true,
float: 123.45,
integer: 123,
string: 'string',
})
})
it('Validates the input', () => {
// dont need to check this extensively since it's the same logic as tested in record validation
expect(() =>
lex.assertValidXrpcInput('com.example.procedure', {
object: { boolean: 'string' },
array: ['one', 'two'],
boolean: true,
float: 123.45,
integer: 123,
string: 'string',
}),
).toThrow('Input/object/boolean must be a boolean')
expect(() => lex.assertValidXrpcInput('com.example.procedure', {})).toThrow(
'Input must have the property "object"',
)
})
})
describe('XRPC output validation', () => {
const lex = new Lexicons(LexiconDocs)
it('Passes valid outputs', () => {
lex.assertValidXrpcOutput('com.example.query', {
object: { boolean: true },
array: ['one', 'two'],
boolean: true,
float: 123.45,
integer: 123,
string: 'string',
})
lex.assertValidXrpcOutput('com.example.procedure', {
object: { boolean: true },
array: ['one', 'two'],
boolean: true,
float: 123.45,
integer: 123,
string: 'string',
})
})
it('Validates the output', () => {
// dont need to check this extensively since it's the same logic as tested in record validation
expect(() =>
lex.assertValidXrpcOutput('com.example.query', {
object: { boolean: 'string' },
array: ['one', 'two'],
boolean: true,
float: 123.45,
integer: 123,
string: 'string',
}),
).toThrow('Output/object/boolean must be a boolean')
expect(() =>
lex.assertValidXrpcOutput('com.example.procedure', {}),
).toThrow('Output must have the property "object"')
})
})