@atproto/lexicon
Version:
atproto Lexicon schema language library
477 lines (423 loc) • 11.4 kB
text/typescript
import { z } from 'zod'
import { NSID } from '@atproto/syntax'
import { requiredPropertiesRefinement } from './util'
// primitives
// =
export const lexBoolean = z
.object({
type: z.literal('boolean'),
description: z.string().optional(),
default: z.boolean().optional(),
const: z.boolean().optional(),
})
.strict()
export type LexBoolean = z.infer<typeof lexBoolean>
export const lexInteger = z
.object({
type: z.literal('integer'),
description: z.string().optional(),
default: z.number().int().optional(),
minimum: z.number().int().optional(),
maximum: z.number().int().optional(),
enum: z.number().int().array().optional(),
const: z.number().int().optional(),
})
.strict()
export type LexInteger = z.infer<typeof lexInteger>
export const lexStringFormat = z.enum([
'datetime',
'uri',
'at-uri',
'did',
'handle',
'at-identifier',
'nsid',
'cid',
'language',
'tid',
'record-key',
])
export type LexStringFormat = z.infer<typeof lexStringFormat>
export const lexString = z
.object({
type: z.literal('string'),
format: lexStringFormat.optional(),
description: z.string().optional(),
default: z.string().optional(),
minLength: z.number().int().optional(),
maxLength: z.number().int().optional(),
minGraphemes: z.number().int().optional(),
maxGraphemes: z.number().int().optional(),
enum: z.string().array().optional(),
const: z.string().optional(),
knownValues: z.string().array().optional(),
})
.strict()
export type LexString = z.infer<typeof lexString>
export const lexUnknown = z
.object({
type: z.literal('unknown'),
description: z.string().optional(),
})
.strict()
export type LexUnknown = z.infer<typeof lexUnknown>
export const lexPrimitive = z.discriminatedUnion('type', [
lexBoolean,
lexInteger,
lexString,
lexUnknown,
])
export type LexPrimitive = z.infer<typeof lexPrimitive>
// ipld types
// =
export const lexBytes = z
.object({
type: z.literal('bytes'),
description: z.string().optional(),
maxLength: z.number().optional(),
minLength: z.number().optional(),
})
.strict()
export type LexBytes = z.infer<typeof lexBytes>
export const lexCidLink = z
.object({
type: z.literal('cid-link'),
description: z.string().optional(),
})
.strict()
export type LexCidLink = z.infer<typeof lexCidLink>
export const lexIpldType = z.discriminatedUnion('type', [lexBytes, lexCidLink])
export type LexIpldType = z.infer<typeof lexIpldType>
// references
// =
export const lexRef = z
.object({
type: z.literal('ref'),
description: z.string().optional(),
ref: z.string(),
})
.strict()
export type LexRef = z.infer<typeof lexRef>
export const lexRefUnion = z
.object({
type: z.literal('union'),
description: z.string().optional(),
refs: z.string().array(),
closed: z.boolean().optional(),
})
.strict()
export type LexRefUnion = z.infer<typeof lexRefUnion>
export const lexRefVariant = z.discriminatedUnion('type', [lexRef, lexRefUnion])
export type LexRefVariant = z.infer<typeof lexRefVariant>
// blobs
// =
export const lexBlob = z
.object({
type: z.literal('blob'),
description: z.string().optional(),
accept: z.string().array().optional(),
maxSize: z.number().optional(),
})
.strict()
export type LexBlob = z.infer<typeof lexBlob>
// complex types
// =
export const lexArray = z
.object({
type: z.literal('array'),
description: z.string().optional(),
items: z.discriminatedUnion('type', [
// lexPrimitive
lexBoolean,
lexInteger,
lexString,
lexUnknown,
// lexIpldType
lexBytes,
lexCidLink,
// lexRefVariant
lexRef,
lexRefUnion,
// other
lexBlob,
]),
minLength: z.number().int().optional(),
maxLength: z.number().int().optional(),
})
.strict()
export type LexArray = z.infer<typeof lexArray>
export const lexPrimitiveArray = lexArray.merge(
z
.object({
items: lexPrimitive,
})
.strict(),
)
export type LexPrimitiveArray = z.infer<typeof lexPrimitiveArray>
export const lexToken = z
.object({
type: z.literal('token'),
description: z.string().optional(),
})
.strict()
export type LexToken = z.infer<typeof lexToken>
export const lexObject = z
.object({
type: z.literal('object'),
description: z.string().optional(),
required: z.string().array().optional(),
nullable: z.string().array().optional(),
properties: z.record(
z.discriminatedUnion('type', [
lexArray,
// lexPrimitive
lexBoolean,
lexInteger,
lexString,
lexUnknown,
// lexIpldType
lexBytes,
lexCidLink,
// lexRefVariant
lexRef,
lexRefUnion,
// other
lexBlob,
]),
),
})
.strict()
.superRefine(requiredPropertiesRefinement)
export type LexObject = z.infer<typeof lexObject>
// xrpc
// =
export const lexXrpcParameters = z
.object({
type: z.literal('params'),
description: z.string().optional(),
required: z.string().array().optional(),
properties: z.record(
z.discriminatedUnion('type', [
lexPrimitiveArray,
// lexPrimitive
lexBoolean,
lexInteger,
lexString,
lexUnknown,
]),
),
})
.strict()
.superRefine(requiredPropertiesRefinement)
export type LexXrpcParameters = z.infer<typeof lexXrpcParameters>
export const lexXrpcBody = z
.object({
description: z.string().optional(),
encoding: z.string(),
// @NOTE using discriminatedUnion with a refined schema requires zod >= 4
schema: z.union([lexRefVariant, lexObject]).optional(),
})
.strict()
export type LexXrpcBody = z.infer<typeof lexXrpcBody>
export const lexXrpcSubscriptionMessage = z
.object({
description: z.string().optional(),
// @NOTE using discriminatedUnion with a refined schema requires zod >= 4
schema: z.union([lexRefVariant, lexObject]).optional(),
})
.strict()
export type LexXrpcSubscriptionMessage = z.infer<
typeof lexXrpcSubscriptionMessage
>
export const lexXrpcError = z
.object({
name: z.string(),
description: z.string().optional(),
})
.strict()
export type LexXrpcError = z.infer<typeof lexXrpcError>
export const lexXrpcQuery = z
.object({
type: z.literal('query'),
description: z.string().optional(),
parameters: lexXrpcParameters.optional(),
output: lexXrpcBody.optional(),
errors: lexXrpcError.array().optional(),
})
.strict()
export type LexXrpcQuery = z.infer<typeof lexXrpcQuery>
export const lexXrpcProcedure = z
.object({
type: z.literal('procedure'),
description: z.string().optional(),
parameters: lexXrpcParameters.optional(),
input: lexXrpcBody.optional(),
output: lexXrpcBody.optional(),
errors: lexXrpcError.array().optional(),
})
.strict()
export type LexXrpcProcedure = z.infer<typeof lexXrpcProcedure>
export const lexXrpcSubscription = z
.object({
type: z.literal('subscription'),
description: z.string().optional(),
parameters: lexXrpcParameters.optional(),
message: lexXrpcSubscriptionMessage.optional(),
errors: lexXrpcError.array().optional(),
})
.strict()
export type LexXrpcSubscription = z.infer<typeof lexXrpcSubscription>
// database
// =
export const lexRecord = z
.object({
type: z.literal('record'),
description: z.string().optional(),
key: z.string().optional(),
record: lexObject,
})
.strict()
export type LexRecord = z.infer<typeof lexRecord>
// core
// =
// We need to use `z.custom` here because
// lexXrpcProperty and lexObject are refined
// `z.union` would work, but it's too slow
// see #915 for details
export const lexUserType = z.custom<
| LexRecord
| LexXrpcQuery
| LexXrpcProcedure
| LexXrpcSubscription
| LexBlob
| LexArray
| LexToken
| LexObject
| LexBoolean
| LexInteger
| LexString
| LexBytes
| LexCidLink
| LexUnknown
>(
(val) => {
if (!val || typeof val !== 'object') {
return
}
if (val['type'] === undefined) {
return
}
switch (val['type']) {
case 'record':
return lexRecord.parse(val)
case 'query':
return lexXrpcQuery.parse(val)
case 'procedure':
return lexXrpcProcedure.parse(val)
case 'subscription':
return lexXrpcSubscription.parse(val)
case 'blob':
return lexBlob.parse(val)
case 'array':
return lexArray.parse(val)
case 'token':
return lexToken.parse(val)
case 'object':
return lexObject.parse(val)
case 'boolean':
return lexBoolean.parse(val)
case 'integer':
return lexInteger.parse(val)
case 'string':
return lexString.parse(val)
case 'bytes':
return lexBytes.parse(val)
case 'cid-link':
return lexCidLink.parse(val)
case 'unknown':
return lexUnknown.parse(val)
}
},
(val) => {
if (!val || typeof val !== 'object') {
return {
message: 'Must be an object',
fatal: true,
}
}
if (val['type'] === undefined) {
return {
message: 'Must have a type',
fatal: true,
}
}
if (typeof val['type'] !== 'string') {
return {
message: 'Type property must be a string',
fatal: true,
}
}
return {
message: `Invalid type: ${val['type']} must be one of: record, query, procedure, subscription, blob, array, token, object, boolean, integer, string, bytes, cid-link, unknown`,
fatal: true,
}
},
)
export type LexUserType = z.infer<typeof lexUserType>
export const lexiconDoc = z
.object({
lexicon: z.literal(1),
id: z.string().refine((v: string) => NSID.isValid(v), {
message: 'Must be a valid NSID',
}),
revision: z.number().optional(),
description: z.string().optional(),
defs: z.record(lexUserType),
})
.strict()
.superRefine((doc, ctx) => {
for (const defId in doc.defs) {
const def = doc.defs[defId]
if (
defId !== 'main' &&
(def.type === 'record' ||
def.type === 'procedure' ||
def.type === 'query' ||
def.type === 'subscription')
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Records, procedures, queries, and subscriptions must be the main definition.`,
})
}
}
})
export type LexiconDoc = z.infer<typeof lexiconDoc>
// helpers
// =
export function isValidLexiconDoc(v: unknown): v is LexiconDoc {
return lexiconDoc.safeParse(v).success
}
export function isObj<V>(v: V): v is V & object {
return v != null && typeof v === 'object'
}
export type DiscriminatedObject = { $type: string }
export function isDiscriminatedObject(v: unknown): v is DiscriminatedObject {
return isObj(v) && '$type' in v && typeof v.$type === 'string'
}
export function parseLexiconDoc(v: unknown): LexiconDoc {
lexiconDoc.parse(v)
return v as LexiconDoc
}
export type ValidationResult<V = unknown> =
| {
success: true
value: V
}
| {
success: false
error: ValidationError
}
export class ValidationError extends Error {}
export class InvalidLexiconError extends Error {}
export class LexiconDefNotFoundError extends Error {}