@atproto/lexicon 0.4.6-next.5 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atproto/lexicon",
3
- "version": "0.4.6-next.5",
3
+ "version": "0.4.6",
4
4
  "license": "MIT",
5
5
  "description": "atproto Lexicon schema language library",
6
6
  "keywords": [
@@ -19,8 +19,8 @@
19
19
  "iso-datestring-validator": "^2.2.2",
20
20
  "multiformats": "^9.9.0",
21
21
  "zod": "^3.23.8",
22
- "@atproto/common-web": "^0.3.2",
23
- "@atproto/syntax": "^0.3.1"
22
+ "@atproto/common-web": "^0.4.0",
23
+ "@atproto/syntax": "^0.3.2"
24
24
  },
25
25
  "devDependencies": {
26
26
  "jest": "^28.1.2",
package/src/lexicons.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  LexiconDoc,
7
7
  ValidationError,
8
8
  ValidationResult,
9
+ hasProp,
9
10
  isObj,
10
11
  } from './types'
11
12
  import { toLexUri } from './util'
@@ -16,7 +17,7 @@ import {
16
17
  assertValidXrpcOutput,
17
18
  assertValidXrpcParams,
18
19
  } from './validation'
19
- import { object as validateObject } from './validators/complex'
20
+ import * as ComplexValidators from './validators/complex'
20
21
 
21
22
  /**
22
23
  * A collection of compiled lexicons.
@@ -126,17 +127,15 @@ export class Lexicons implements Iterable<LexiconDoc> {
126
127
  * Validate a record or object.
127
128
  */
128
129
  validate(lexUri: string, value: unknown): ValidationResult {
130
+ lexUri = toLexUri(lexUri)
131
+ const def = this.getDefOrThrow(lexUri, ['record', 'object'])
129
132
  if (!isObj(value)) {
130
133
  throw new ValidationError(`Value must be an object`)
131
134
  }
132
-
133
- const lexUriNormalized = toLexUri(lexUri)
134
- const def = this.getDefOrThrow(lexUriNormalized, ['record', 'object'])
135
-
136
135
  if (def.type === 'record') {
137
- return validateObject(this, 'Record', def.record, value)
136
+ return ComplexValidators.object(this, 'Record', def.record, value)
138
137
  } else if (def.type === 'object') {
139
- return validateObject(this, 'Object', def, value)
138
+ return ComplexValidators.object(this, 'Object', def, value)
140
139
  } else {
141
140
  // shouldn't happen
142
141
  throw new InvalidLexiconError('Definition must be a record or object')
@@ -147,25 +146,20 @@ export class Lexicons implements Iterable<LexiconDoc> {
147
146
  * Validate a record and throw on any error.
148
147
  */
149
148
  assertValidRecord(lexUri: string, value: unknown) {
149
+ lexUri = toLexUri(lexUri)
150
+ const def = this.getDefOrThrow(lexUri, ['record'])
150
151
  if (!isObj(value)) {
151
152
  throw new ValidationError(`Record must be an object`)
152
153
  }
153
- if (!('$type' in value)) {
154
+ if (!hasProp(value, '$type') || typeof value.$type !== 'string') {
154
155
  throw new ValidationError(`Record/$type must be a string`)
155
156
  }
156
- const { $type } = value
157
- if (typeof $type !== 'string') {
158
- throw new ValidationError(`Record/$type must be a string`)
159
- }
160
-
161
- const lexUriNormalized = toLexUri(lexUri)
162
- if (toLexUri($type) !== lexUriNormalized) {
157
+ const $type = (value as Record<string, string>).$type || ''
158
+ if (toLexUri($type) !== lexUri) {
163
159
  throw new ValidationError(
164
- `Invalid $type: must be ${lexUriNormalized}, got ${$type}`,
160
+ `Invalid $type: must be ${lexUri}, got ${$type}`,
165
161
  )
166
162
  }
167
-
168
- const def = this.getDefOrThrow(lexUriNormalized, ['record'])
169
163
  return assertValidRecord(this, def as LexRecord, value)
170
164
  }
171
165
 
package/src/types.ts CHANGED
@@ -447,13 +447,23 @@ export function isValidLexiconDoc(v: unknown): v is LexiconDoc {
447
447
  return lexiconDoc.safeParse(v).success
448
448
  }
449
449
 
450
- export function isObj<V>(v: V): v is V & object {
451
- return v != null && typeof v === 'object'
450
+ export function isObj(obj: unknown): obj is Record<string, unknown> {
451
+ return obj !== null && typeof obj === 'object'
452
452
  }
453
453
 
454
- export type DiscriminatedObject = { $type: string }
455
- export function isDiscriminatedObject(v: unknown): v is DiscriminatedObject {
456
- return isObj(v) && '$type' in v && typeof v.$type === 'string'
454
+ export function hasProp<K extends PropertyKey>(
455
+ data: object,
456
+ prop: K,
457
+ ): data is Record<K, unknown> {
458
+ return prop in data
459
+ }
460
+
461
+ export const discriminatedObject = z.object({ $type: z.string() })
462
+ export type DiscriminatedObject = z.infer<typeof discriminatedObject>
463
+ export function isDiscriminatedObject(
464
+ value: unknown,
465
+ ): value is DiscriminatedObject {
466
+ return discriminatedObject.safeParse(value).success
457
467
  }
458
468
 
459
469
  export function parseLexiconDoc(v: unknown): LexiconDoc {
@@ -461,10 +471,10 @@ export function parseLexiconDoc(v: unknown): LexiconDoc {
461
471
  return v as LexiconDoc
462
472
  }
463
473
 
464
- export type ValidationResult<V = unknown> =
474
+ export type ValidationResult =
465
475
  | {
466
476
  success: true
467
- value: V
477
+ value: unknown
468
478
  }
469
479
  | {
470
480
  success: false
package/src/util.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import { z } from 'zod'
2
+ import { Lexicons } from './lexicons'
3
+ import { LexRefVariant, LexUserType } from './types'
2
4
 
3
5
  export function toLexUri(str: string, baseUri?: string): string {
4
6
  if (str.split('#').length > 2) {
@@ -17,6 +19,19 @@ export function toLexUri(str: string, baseUri?: string): string {
17
19
  return `lex:${str}`
18
20
  }
19
21
 
22
+ export function toConcreteTypes(
23
+ lexicons: Lexicons,
24
+ def: LexRefVariant | LexUserType,
25
+ ): LexUserType[] {
26
+ if (def.type === 'ref') {
27
+ return [lexicons.getDefOrThrow(def.ref)]
28
+ } else if (def.type === 'union') {
29
+ return def.refs.map((ref) => lexicons.getDefOrThrow(ref)).flat()
30
+ } else {
31
+ return [def]
32
+ }
33
+ }
34
+
20
35
  export function requiredPropertiesRefinement<
21
36
  ObjectType extends {
22
37
  required?: string[]
@@ -1,16 +1,16 @@
1
1
  import { Lexicons } from '../lexicons'
2
2
  import {
3
3
  LexArray,
4
+ LexObject,
4
5
  LexRefVariant,
5
6
  LexUserType,
6
7
  ValidationError,
7
8
  ValidationResult,
8
9
  isDiscriminatedObject,
9
- isObj,
10
10
  } from '../types'
11
- import { toLexUri } from '../util'
11
+ import { toConcreteTypes, toLexUri } from '../util'
12
12
  import { blob } from './blob'
13
- import { validate as validatePrimitive } from './primitives'
13
+ import { boolean, bytes, cidLink, integer, string, unknown } from './primitives'
14
14
 
15
15
  export function validate(
16
16
  lexicons: Lexicons,
@@ -19,6 +19,18 @@ export function validate(
19
19
  value: unknown,
20
20
  ): ValidationResult {
21
21
  switch (def.type) {
22
+ case 'boolean':
23
+ return boolean(lexicons, path, def, value)
24
+ case 'integer':
25
+ return integer(lexicons, path, def, value)
26
+ case 'string':
27
+ return string(lexicons, path, def, value)
28
+ case 'bytes':
29
+ return bytes(lexicons, path, def, value)
30
+ case 'cid-link':
31
+ return cidLink(lexicons, path, def, value)
32
+ case 'unknown':
33
+ return unknown(lexicons, path, def, value)
22
34
  case 'object':
23
35
  return object(lexicons, path, def, value)
24
36
  case 'array':
@@ -26,7 +38,10 @@ export function validate(
26
38
  case 'blob':
27
39
  return blob(lexicons, path, def, value)
28
40
  default:
29
- return validatePrimitive(lexicons, path, def, value)
41
+ return {
42
+ success: false,
43
+ error: new ValidationError(`Unexpected lexicon type: ${def.type}`),
44
+ }
30
45
  }
31
46
  }
32
47
 
@@ -88,31 +103,35 @@ export function object(
88
103
  def: LexUserType,
89
104
  value: unknown,
90
105
  ): ValidationResult {
106
+ def = def as LexObject
107
+
91
108
  // type
92
- if (!isObj(value)) {
109
+ if (!value || typeof value !== 'object') {
93
110
  return {
94
111
  success: false,
95
112
  error: new ValidationError(`${path} must be an object`),
96
113
  }
97
114
  }
98
115
 
116
+ const requiredProps = new Set(def.required)
117
+ const nullableProps = new Set(def.nullable)
118
+
99
119
  // properties
100
120
  let resultValue = value
101
- if ('properties' in def && def.properties != null) {
121
+ if (typeof def.properties === 'object') {
102
122
  for (const key in def.properties) {
103
- const keyValue = value[key]
104
- if (keyValue === null && def.nullable?.includes(key)) {
123
+ if (value[key] === null && nullableProps.has(key)) {
105
124
  continue
106
125
  }
107
126
  const propDef = def.properties[key]
108
- if (keyValue === undefined && !def.required?.includes(key)) {
127
+ if (typeof value[key] === 'undefined' && !requiredProps.has(key)) {
109
128
  // Fast path for non-required undefined props.
110
129
  if (
111
130
  propDef.type === 'integer' ||
112
131
  propDef.type === 'boolean' ||
113
132
  propDef.type === 'string'
114
133
  ) {
115
- if (propDef.default === undefined) {
134
+ if (typeof propDef.default === 'undefined') {
116
135
  continue
117
136
  }
118
137
  } else {
@@ -121,27 +140,20 @@ export function object(
121
140
  }
122
141
  }
123
142
  const propPath = `${path}/${key}`
124
- const validated = validateOneOf(lexicons, propPath, propDef, keyValue)
125
- const propValue = validated.success ? validated.value : keyValue
126
-
143
+ const validated = validateOneOf(lexicons, propPath, propDef, value[key])
144
+ const propValue = validated.success ? validated.value : value[key]
145
+ const propIsUndefined = typeof propValue === 'undefined'
127
146
  // Return error for bad validation, giving required rule precedence
128
- if (propValue === undefined) {
129
- if (def.required?.includes(key)) {
130
- return {
131
- success: false,
132
- error: new ValidationError(
133
- `${path} must have the property "${key}"`,
134
- ),
135
- }
136
- }
137
- } else {
138
- if (!validated.success) {
139
- return validated
147
+ if (propIsUndefined && requiredProps.has(key)) {
148
+ return {
149
+ success: false,
150
+ error: new ValidationError(`${path} must have the property "${key}"`),
140
151
  }
152
+ } else if (!propIsUndefined && !validated.success) {
153
+ return validated
141
154
  }
142
-
143
155
  // Adjust value based on e.g. applied defaults, cloning shallowly if there was a changed value
144
- if (propValue !== keyValue) {
156
+ if (propValue !== value[key]) {
145
157
  if (resultValue === value) {
146
158
  // Lazy shallow clone
147
159
  resultValue = { ...value }
@@ -161,8 +173,9 @@ export function validateOneOf(
161
173
  value: unknown,
162
174
  mustBeObj = false, // this is the only type constraint we need currently (used by xrpc body schema validators)
163
175
  ): ValidationResult {
164
- let concreteDef: LexUserType
176
+ let error
165
177
 
178
+ let concreteDefs
166
179
  if (def.type === 'union') {
167
180
  if (!isDiscriminatedObject(value)) {
168
181
  return {
@@ -183,17 +196,33 @@ export function validateOneOf(
183
196
  }
184
197
  return { success: true, value }
185
198
  } else {
186
- concreteDef = lexicons.getDefOrThrow(value.$type)
199
+ concreteDefs = toConcreteTypes(lexicons, {
200
+ type: 'ref',
201
+ ref: value.$type,
202
+ })
187
203
  }
188
- } else if (def.type === 'ref') {
189
- concreteDef = lexicons.getDefOrThrow(def.ref)
190
204
  } else {
191
- concreteDef = def
205
+ concreteDefs = toConcreteTypes(lexicons, def)
192
206
  }
193
207
 
194
- return mustBeObj
195
- ? object(lexicons, path, concreteDef, value)
196
- : validate(lexicons, path, concreteDef, value)
208
+ for (const concreteDef of concreteDefs) {
209
+ const result = mustBeObj
210
+ ? object(lexicons, path, concreteDef, value)
211
+ : validate(lexicons, path, concreteDef, value)
212
+ if (result.success) {
213
+ return result
214
+ }
215
+ error ??= result.error
216
+ }
217
+ if (concreteDefs.length > 1) {
218
+ return {
219
+ success: false,
220
+ error: new ValidationError(
221
+ `${path} did not match any of the expected definitions`,
222
+ ),
223
+ }
224
+ }
225
+ return { success: false, error }
197
226
  }
198
227
 
199
228
  // to avoid bugs like #0189 this needs to handle both
@@ -205,7 +234,7 @@ const refsContainType = (refs: string[], type: string) => {
205
234
  }
206
235
 
207
236
  if (lexUri.endsWith('#main')) {
208
- return refs.includes(lexUri.slice(0, -5))
237
+ return refs.includes(lexUri.replace('#main', ''))
209
238
  } else {
210
239
  return refs.includes(lexUri + '#main')
211
240
  }
@@ -7,7 +7,7 @@ import {
7
7
  ensureValidHandle,
8
8
  ensureValidNsid,
9
9
  ensureValidRecordKey,
10
- isValidTid,
10
+ ensureValidTid,
11
11
  } from '@atproto/syntax'
12
12
  import { ValidationError, ValidationResult } from '../types'
13
13
 
@@ -131,14 +131,17 @@ export function language(path: string, value: string): ValidationResult {
131
131
  }
132
132
 
133
133
  export function tid(path: string, value: string): ValidationResult {
134
- if (isValidTid(value)) {
135
- return { success: true, value }
136
- }
137
-
138
- return {
139
- success: false,
140
- error: new ValidationError(`${path} must be a valid TID`),
134
+ try {
135
+ ensureValidTid(value)
136
+ } catch {
137
+ return {
138
+ success: false,
139
+ error: new ValidationError(
140
+ `${path} must be a valid TID (timestamp identifier)`,
141
+ ),
142
+ }
141
143
  }
144
+ return { success: true, value }
142
145
  }
143
146
 
144
147
  export function recordKey(path: string, value: string): ValidationResult {
@@ -39,7 +39,7 @@ export function validate(
39
39
  }
40
40
  }
41
41
 
42
- function boolean(
42
+ export function boolean(
43
43
  lexicons: Lexicons,
44
44
  path: string,
45
45
  def: LexUserType,
@@ -77,7 +77,7 @@ function boolean(
77
77
  return { success: true, value }
78
78
  }
79
79
 
80
- function integer(
80
+ export function integer(
81
81
  lexicons: Lexicons,
82
82
  path: string,
83
83
  def: LexUserType,
@@ -151,7 +151,7 @@ function integer(
151
151
  return { success: true, value }
152
152
  }
153
153
 
154
- function string(
154
+ export function string(
155
155
  lexicons: Lexicons,
156
156
  path: string,
157
157
  def: LexUserType,
@@ -340,7 +340,7 @@ function string(
340
340
  return { success: true, value }
341
341
  }
342
342
 
343
- function bytes(
343
+ export function bytes(
344
344
  lexicons: Lexicons,
345
345
  path: string,
346
346
  def: LexUserType,
@@ -382,7 +382,7 @@ function bytes(
382
382
  return { success: true, value }
383
383
  }
384
384
 
385
- function cidLink(
385
+ export function cidLink(
386
386
  lexicons: Lexicons,
387
387
  path: string,
388
388
  def: LexUserType,
@@ -398,7 +398,7 @@ function cidLink(
398
398
  return { success: true, value }
399
399
  }
400
400
 
401
- function unknown(
401
+ export function unknown(
402
402
  lexicons: Lexicons,
403
403
  path: string,
404
404
  def: LexUserType,