@atproto/lex-schema 0.0.16 → 0.0.18
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/CHANGELOG.md +30 -0
- package/dist/core/schema.d.ts +5 -11
- package/dist/core/schema.d.ts.map +1 -1
- package/dist/core/schema.js +10 -16
- package/dist/core/schema.js.map +1 -1
- package/dist/core/validation-error.d.ts +2 -2
- package/dist/core/validation-error.d.ts.map +1 -1
- package/dist/core/validation-error.js +1 -1
- package/dist/core/validation-error.js.map +1 -1
- package/dist/core/validation-issue.d.ts.map +1 -1
- package/dist/core/validation-issue.js +19 -38
- package/dist/core/validation-issue.js.map +1 -1
- package/dist/helpers.d.ts +4 -3
- package/dist/helpers.d.ts.map +1 -1
- package/dist/helpers.js +6 -1
- package/dist/helpers.js.map +1 -1
- package/dist/schema/blob.d.ts +6 -20
- package/dist/schema/blob.d.ts.map +1 -1
- package/dist/schema/blob.js +23 -28
- package/dist/schema/blob.js.map +1 -1
- package/dist/schema/payload.d.ts.map +1 -1
- package/dist/schema/payload.js +2 -3
- package/dist/schema/payload.js.map +1 -1
- package/dist/schema/record.d.ts +6 -8
- package/dist/schema/record.d.ts.map +1 -1
- package/dist/schema/record.js +1 -1
- package/dist/schema/record.js.map +1 -1
- package/dist/schema/regexp.d.ts +3 -2
- package/dist/schema/regexp.d.ts.map +1 -1
- package/dist/schema/regexp.js +6 -4
- package/dist/schema/regexp.js.map +1 -1
- package/dist/schema/string.d.ts.map +1 -1
- package/dist/schema/string.js +9 -2
- package/dist/schema/string.js.map +1 -1
- package/dist/schema/typed-object.d.ts +5 -7
- package/dist/schema/typed-object.d.ts.map +1 -1
- package/dist/schema/typed-object.js +1 -1
- package/dist/schema/typed-object.js.map +1 -1
- package/package.json +3 -3
- package/src/core/$type.test.ts +9 -5
- package/src/core/schema.ts +19 -16
- package/src/core/validation-error.ts +2 -2
- package/src/core/validation-issue.ts +20 -36
- package/src/helpers.ts +7 -1
- package/src/schema/array.test.ts +1 -1
- package/src/schema/blob.test.ts +223 -263
- package/src/schema/blob.ts +27 -46
- package/src/schema/params.test.ts +2 -2
- package/src/schema/payload.ts +2 -3
- package/src/schema/record.test.ts +135 -17
- package/src/schema/record.ts +14 -9
- package/src/schema/regexp.ts +14 -4
- package/src/schema/string.ts +8 -2
- package/src/schema/typed-object.test.ts +77 -0
- package/src/schema/typed-object.ts +11 -10
package/src/schema/blob.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
2
|
BlobRef,
|
|
3
3
|
LegacyBlobRef,
|
|
4
|
+
TypedBlobRef,
|
|
5
|
+
getBlobSize,
|
|
4
6
|
isBlobRef,
|
|
5
7
|
isLegacyBlobRef,
|
|
6
|
-
|
|
8
|
+
isTypedBlobRef,
|
|
7
9
|
} from '@atproto/lex-data'
|
|
8
10
|
import { Schema, ValidationContext } from '../core.js'
|
|
9
11
|
import { memoizedOptions } from '../util/memoize.js'
|
|
@@ -12,14 +14,6 @@ import { memoizedOptions } from '../util/memoize.js'
|
|
|
12
14
|
* Configuration options for blob schema validation.
|
|
13
15
|
*/
|
|
14
16
|
export type BlobSchemaOptions = {
|
|
15
|
-
/**
|
|
16
|
-
* Whether to allow legacy blob references format
|
|
17
|
-
*
|
|
18
|
-
* @default false
|
|
19
|
-
* @see {@link LegacyBlobRef}
|
|
20
|
-
*/
|
|
21
|
-
allowLegacy?: boolean
|
|
22
|
-
|
|
23
17
|
/**
|
|
24
18
|
* List of accepted MIME types (supports wildcards like 'image/*' or '*\/*')
|
|
25
19
|
*
|
|
@@ -35,8 +29,8 @@ export type BlobSchemaOptions = {
|
|
|
35
29
|
maxSize?: number
|
|
36
30
|
}
|
|
37
31
|
|
|
38
|
-
export type { BlobRef, LegacyBlobRef }
|
|
39
|
-
export { isBlobRef, isLegacyBlobRef }
|
|
32
|
+
export type { BlobRef, LegacyBlobRef, TypedBlobRef }
|
|
33
|
+
export { isBlobRef, isLegacyBlobRef, isTypedBlobRef }
|
|
40
34
|
|
|
41
35
|
/**
|
|
42
36
|
* Schema for validating blob references in AT Protocol.
|
|
@@ -55,9 +49,7 @@ export { isBlobRef, isLegacyBlobRef }
|
|
|
55
49
|
*/
|
|
56
50
|
export class BlobSchema<
|
|
57
51
|
const TOptions extends BlobSchemaOptions = NonNullable<unknown>,
|
|
58
|
-
> extends Schema<
|
|
59
|
-
TOptions extends { allowLegacy: true } ? BlobRef | LegacyBlobRef : BlobRef
|
|
60
|
-
> {
|
|
52
|
+
> extends Schema<BlobRef> {
|
|
61
53
|
readonly type = 'blob' as const
|
|
62
54
|
|
|
63
55
|
constructor(readonly options?: TOptions) {
|
|
@@ -65,23 +57,29 @@ export class BlobSchema<
|
|
|
65
57
|
}
|
|
66
58
|
|
|
67
59
|
validateInContext(input: unknown, ctx: ValidationContext) {
|
|
68
|
-
const blob = parseValue.call(ctx, input
|
|
69
|
-
|
|
60
|
+
const blob = parseValue.call(ctx, input)
|
|
70
61
|
if (!blob) {
|
|
71
62
|
return ctx.issueUnexpectedType(input, 'blob')
|
|
72
63
|
}
|
|
73
64
|
|
|
74
65
|
// In non-strict mode, we allow blob refs to pass through without MIME
|
|
75
66
|
// type or size checks.
|
|
76
|
-
if (ctx.options.strict) {
|
|
77
|
-
const accept = this.options
|
|
67
|
+
if (ctx.options.strict && this.options != null) {
|
|
68
|
+
const { accept } = this.options
|
|
78
69
|
if (accept && !matchesMime(blob.mimeType, accept)) {
|
|
79
70
|
return ctx.issueInvalidPropertyValue(blob, 'mimeType', accept)
|
|
80
71
|
}
|
|
81
72
|
|
|
82
|
-
const maxSize = this.options
|
|
83
|
-
if (maxSize != null
|
|
84
|
-
|
|
73
|
+
const { maxSize } = this.options
|
|
74
|
+
if (maxSize != null) {
|
|
75
|
+
const size = getBlobSize(blob)
|
|
76
|
+
if (size === undefined) {
|
|
77
|
+
// Unable to enforce size constraint if size is not available (legacy
|
|
78
|
+
// blob ref), so we treat it as a validation failure in strict mode.
|
|
79
|
+
return ctx.issueInvalidPropertyType(blob, 'size' as any, 'integer')
|
|
80
|
+
} else if (size > maxSize) {
|
|
81
|
+
return ctx.issueTooBig(blob, 'blob', maxSize, size)
|
|
82
|
+
}
|
|
85
83
|
}
|
|
86
84
|
}
|
|
87
85
|
|
|
@@ -95,33 +93,19 @@ export class BlobSchema<
|
|
|
95
93
|
}
|
|
96
94
|
}
|
|
97
95
|
|
|
98
|
-
function parseValue(
|
|
99
|
-
|
|
100
|
-
input: unknown,
|
|
101
|
-
options?: BlobSchemaOptions,
|
|
102
|
-
): BlobRef | LegacyBlobRef | null {
|
|
103
|
-
// If there is a $type property, we treat if as a potential BlobRef and
|
|
96
|
+
function parseValue(this: ValidationContext, input: unknown): BlobRef | null {
|
|
97
|
+
// If there is a $type property, we treat if as a potential TypedBlobRef and
|
|
104
98
|
// validate accordingly.
|
|
105
99
|
if ((input as any)?.$type !== undefined) {
|
|
106
100
|
// Use the context's option for the "strict" check
|
|
107
|
-
return
|
|
101
|
+
return isTypedBlobRef(input, this.options) ? input : null
|
|
108
102
|
}
|
|
109
103
|
|
|
110
104
|
// If there is no $type property, we may be dealing with a legacy blob ref. If
|
|
111
|
-
// legacy refs are allowed,
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if (options?.allowLegacy) {
|
|
116
|
-
if (isLegacyBlobRef(input)) {
|
|
117
|
-
return input
|
|
118
|
-
}
|
|
119
|
-
} else if (!this.options.strict && this.options.mode === 'parse') {
|
|
120
|
-
if (isLegacyBlobRef(input)) {
|
|
121
|
-
const { cid, mimeType } = input
|
|
122
|
-
const ref = parseCidSafe(cid)
|
|
123
|
-
if (ref) return { $type: 'blob', ref, mimeType, size: -1 }
|
|
124
|
-
}
|
|
105
|
+
// legacy refs are allowed (non-strict mode), we check if the input matches
|
|
106
|
+
// the legacy format.
|
|
107
|
+
if (!this.options.strict) {
|
|
108
|
+
if (isLegacyBlobRef(input, this.options)) return input
|
|
125
109
|
}
|
|
126
110
|
|
|
127
111
|
return null
|
|
@@ -157,13 +141,10 @@ function matchesMime(mime: string, accepted: string[]): boolean {
|
|
|
157
141
|
*
|
|
158
142
|
* // Any image type with size limit
|
|
159
143
|
* const avatarSchema = l.blob({ accept: ['image/*'], maxSize: 1000000 })
|
|
160
|
-
*
|
|
161
|
-
* // Allow legacy format
|
|
162
|
-
* const legacySchema = l.blob({ allowLegacy: true })
|
|
163
144
|
* ```
|
|
164
145
|
*/
|
|
165
146
|
export const blob = /*#__PURE__*/ memoizedOptions(function <
|
|
166
|
-
O extends BlobSchemaOptions =
|
|
147
|
+
O extends BlobSchemaOptions = NonNullable<unknown>,
|
|
167
148
|
>(options?: O) {
|
|
168
149
|
return new BlobSchema(options)
|
|
169
150
|
})
|
|
@@ -419,7 +419,7 @@ describe('ParamsSchema', () => {
|
|
|
419
419
|
['name', 'Alice'],
|
|
420
420
|
['bools', 'notabool'],
|
|
421
421
|
]),
|
|
422
|
-
).toThrow('Expected boolean value type (got
|
|
422
|
+
).toThrow('Expected boolean value type (got "notabool") at $.bools')
|
|
423
423
|
|
|
424
424
|
expect(() =>
|
|
425
425
|
schema.fromURLSearchParams(
|
|
@@ -431,7 +431,7 @@ describe('ParamsSchema', () => {
|
|
|
431
431
|
path: ['foo', 'bar'],
|
|
432
432
|
},
|
|
433
433
|
),
|
|
434
|
-
).toThrow('Expected boolean value type (got
|
|
434
|
+
).toThrow('Expected boolean value type (got "2") at $.foo.bar.bools')
|
|
435
435
|
})
|
|
436
436
|
|
|
437
437
|
it('ignores empty string values', () => {
|
package/src/schema/payload.ts
CHANGED
|
@@ -109,10 +109,9 @@ export class Payload<
|
|
|
109
109
|
matchesEncoding(contentType: string | undefined): boolean {
|
|
110
110
|
const { encoding } = this
|
|
111
111
|
|
|
112
|
-
// Handle undefined cases
|
|
113
112
|
if (encoding === undefined) {
|
|
114
|
-
//
|
|
115
|
-
return
|
|
113
|
+
// When the output is not defined, we don't enforce any rule on the payload.
|
|
114
|
+
return true
|
|
116
115
|
} else if (contentType == null) {
|
|
117
116
|
// Expecting a body, but got no content-type
|
|
118
117
|
return false
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import { Infer, Unknown$Type, Unknown$TypedObject } from '../core.js'
|
|
1
|
+
import { describe, expect, expectTypeOf, it } from 'vitest'
|
|
2
|
+
import { DidString, Infer, Unknown$Type, Unknown$TypedObject } from '../core.js'
|
|
3
|
+
import { integer } from './integer.js'
|
|
3
4
|
import { object } from './object.js'
|
|
5
|
+
import { optional } from './optional.js'
|
|
4
6
|
import { record } from './record.js'
|
|
5
7
|
import { string } from './string.js'
|
|
6
8
|
|
|
@@ -123,41 +125,157 @@ describe('RecordSchema', () => {
|
|
|
123
125
|
})
|
|
124
126
|
})
|
|
125
127
|
|
|
126
|
-
describe('build method', () => {
|
|
128
|
+
describe('build() method', () => {
|
|
127
129
|
const schema = record(
|
|
128
130
|
'any',
|
|
129
|
-
'
|
|
131
|
+
'io.example.record',
|
|
130
132
|
object({
|
|
131
|
-
|
|
132
|
-
text: string(),
|
|
133
|
+
actor: string({ format: 'did' }),
|
|
134
|
+
text: optional(string()),
|
|
133
135
|
}),
|
|
134
136
|
)
|
|
135
137
|
|
|
136
|
-
it('adds
|
|
137
|
-
const result = schema.build({
|
|
138
|
-
|
|
139
|
-
|
|
138
|
+
it('adds $type to input', () => {
|
|
139
|
+
const result = schema.build({
|
|
140
|
+
actor: 'did:foo:bar',
|
|
141
|
+
text: 'Hello',
|
|
142
|
+
})
|
|
143
|
+
expect(result).toStrictEqual({
|
|
144
|
+
$type: 'io.example.record',
|
|
145
|
+
actor: 'did:foo:bar',
|
|
146
|
+
text: 'Hello',
|
|
147
|
+
})
|
|
148
|
+
expectTypeOf(result).toEqualTypeOf<{
|
|
149
|
+
$type: 'io.example.record'
|
|
150
|
+
actor: DidString
|
|
151
|
+
text?: string
|
|
152
|
+
}>()
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('causes a type error for invalid values', () => {
|
|
156
|
+
const result = schema.build({
|
|
157
|
+
// @ts-expect-error
|
|
158
|
+
actor: 3,
|
|
159
|
+
text: 'Hello',
|
|
160
|
+
})
|
|
161
|
+
expectTypeOf(result).toEqualTypeOf<{
|
|
162
|
+
$type: 'io.example.record'
|
|
163
|
+
actor: DidString
|
|
164
|
+
text?: string
|
|
165
|
+
}>()
|
|
140
166
|
})
|
|
141
167
|
|
|
142
168
|
it('preserves existing properties', () => {
|
|
143
169
|
const result = schema.build({
|
|
144
|
-
|
|
170
|
+
actor: 'did:foo:bar',
|
|
145
171
|
// @ts-expect-error
|
|
146
172
|
extra: 'value',
|
|
147
173
|
})
|
|
148
|
-
expect(result
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
174
|
+
expect(result).toStrictEqual({
|
|
175
|
+
$type: 'io.example.record',
|
|
176
|
+
actor: 'did:foo:bar',
|
|
177
|
+
extra: 'value',
|
|
178
|
+
})
|
|
179
|
+
expectTypeOf(result).toEqualTypeOf<{
|
|
180
|
+
actor: `did:${string}:${string}`
|
|
181
|
+
text?: string | undefined
|
|
182
|
+
$type: 'io.example.record'
|
|
183
|
+
}>()
|
|
152
184
|
})
|
|
153
185
|
|
|
154
186
|
it('overwrites existing $type', () => {
|
|
155
187
|
const result = schema.build({
|
|
156
188
|
// @ts-expect-error
|
|
157
189
|
$type: 'wrong.type',
|
|
158
|
-
|
|
190
|
+
actor: 'did:foo:bar',
|
|
191
|
+
})
|
|
192
|
+
expect(result).toStrictEqual({
|
|
193
|
+
$type: 'io.example.record',
|
|
194
|
+
actor: 'did:foo:bar',
|
|
195
|
+
})
|
|
196
|
+
expectTypeOf(result).toEqualTypeOf<{
|
|
197
|
+
$type: 'io.example.record'
|
|
198
|
+
actor: DidString
|
|
199
|
+
text?: string
|
|
200
|
+
}>()
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
describe('build() does not validate', () => {
|
|
204
|
+
const schema = record(
|
|
205
|
+
'any',
|
|
206
|
+
'io.example.record',
|
|
207
|
+
object({
|
|
208
|
+
actor: string({ format: 'did' }),
|
|
209
|
+
count: integer(),
|
|
210
|
+
}),
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
it('does not throw for invalid data', () => {
|
|
214
|
+
const result = schema.build({
|
|
215
|
+
// @ts-expect-error
|
|
216
|
+
actor: 'not-a-did',
|
|
217
|
+
count: 123,
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
expect(result.$type).toBe('io.example.record')
|
|
221
|
+
expect(result.actor).toBe('not-a-did')
|
|
222
|
+
expect(result.count).toBe(123)
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('does not throw for invalid types', () => {
|
|
226
|
+
const result = schema.build({
|
|
227
|
+
actor: 'did:plc:abc123',
|
|
228
|
+
// @ts-expect-error
|
|
229
|
+
count: 'not-a-number',
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
expect(result.$type).toBe('io.example.record')
|
|
233
|
+
expect(result.count).toBe('not-a-number')
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('does not throw for missing required fields', () => {
|
|
237
|
+
// @ts-expect-error
|
|
238
|
+
const result = schema.build({})
|
|
239
|
+
|
|
240
|
+
expect(result.$type).toBe('io.example.record')
|
|
241
|
+
expect(result.actor).toBeUndefined()
|
|
242
|
+
expect(result.count).toBeUndefined()
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('does not throw for extra fields', () => {
|
|
246
|
+
const result = schema.build({
|
|
247
|
+
actor: 'did:plc:abc123',
|
|
248
|
+
count: 42,
|
|
249
|
+
// @ts-expect-error
|
|
250
|
+
extra: 'unexpected',
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
expect(result.$type).toBe('io.example.record')
|
|
254
|
+
|
|
255
|
+
// @ts-expect-error
|
|
256
|
+
expect(result.extra).toBe('unexpected')
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('parse() still validates after build()', () => {
|
|
260
|
+
const built = schema.build({
|
|
261
|
+
// @ts-expect-error
|
|
262
|
+
actor: 'not-a-did',
|
|
263
|
+
count: 123,
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
expect(() => schema.parse(built)).toThrow('Invalid DID')
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('safeParse() can detect validation errors after build()', () => {
|
|
270
|
+
const built = schema.build({
|
|
271
|
+
// @ts-expect-error
|
|
272
|
+
actor: 'not-a-did',
|
|
273
|
+
count: 123,
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
const result = schema.safeParse(built)
|
|
277
|
+
expect(result.success).toBe(false)
|
|
159
278
|
})
|
|
160
|
-
expect(result.$type).toBe('app.bsky.feed.post')
|
|
161
279
|
})
|
|
162
280
|
})
|
|
163
281
|
|
package/src/schema/record.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { LexMap } from '@atproto/lex-data'
|
|
1
2
|
import {
|
|
2
3
|
$Typed,
|
|
3
4
|
$typed,
|
|
@@ -51,9 +52,9 @@ export type TypedRecord<
|
|
|
51
52
|
* ```
|
|
52
53
|
*/
|
|
53
54
|
export class RecordSchema<
|
|
54
|
-
const TKey extends LexiconRecordKey =
|
|
55
|
-
const TType extends NsidString =
|
|
56
|
-
const TShape extends Validator<
|
|
55
|
+
const TKey extends LexiconRecordKey = LexiconRecordKey,
|
|
56
|
+
const TType extends NsidString = NsidString,
|
|
57
|
+
const TShape extends Validator<LexMap> = Validator<LexMap>,
|
|
57
58
|
> extends Schema<
|
|
58
59
|
$Typed<InferInput<TShape>, TType>,
|
|
59
60
|
$Typed<InferOutput<TShape>, TType>
|
|
@@ -86,9 +87,13 @@ export class RecordSchema<
|
|
|
86
87
|
}
|
|
87
88
|
|
|
88
89
|
build(
|
|
89
|
-
input: Omit<
|
|
90
|
-
): $Typed<InferOutput<
|
|
91
|
-
|
|
90
|
+
input: Omit<InferOutput<TShape>, '$type'>,
|
|
91
|
+
): $Typed<InferOutput<TShape>, TType>
|
|
92
|
+
build(
|
|
93
|
+
input: Omit<InferInput<TShape>, '$type'>,
|
|
94
|
+
): $Typed<InferInput<TShape>, TType>
|
|
95
|
+
build(input: Record<string, unknown>) {
|
|
96
|
+
return $typed(input, this.$type)
|
|
92
97
|
}
|
|
93
98
|
|
|
94
99
|
isTypeOf<TValue extends { $type?: unknown }>(
|
|
@@ -199,11 +204,11 @@ type AsNsid<T> = T extends `${string}#${string}` ? never : T
|
|
|
199
204
|
export function record<
|
|
200
205
|
const K extends LexiconRecordKey,
|
|
201
206
|
const T extends NsidString,
|
|
202
|
-
const S extends Validator<
|
|
207
|
+
const S extends Validator<LexMap>,
|
|
203
208
|
>(key: K, type: AsNsid<T>, validator: S): RecordSchema<K, T, S>
|
|
204
209
|
export function record<
|
|
205
210
|
const K extends LexiconRecordKey,
|
|
206
|
-
const V extends { $type: NsidString },
|
|
211
|
+
const V extends LexMap & { $type: NsidString },
|
|
207
212
|
>(
|
|
208
213
|
key: K,
|
|
209
214
|
type: AsNsid<V['$type']>,
|
|
@@ -213,7 +218,7 @@ export function record<
|
|
|
213
218
|
export function record<
|
|
214
219
|
const K extends LexiconRecordKey,
|
|
215
220
|
const T extends NsidString,
|
|
216
|
-
const S extends Validator<
|
|
221
|
+
const S extends Validator<LexMap>,
|
|
217
222
|
>(key: K, type: T, validator: S) {
|
|
218
223
|
return new RecordSchema<K, T, S>(key, type, validator)
|
|
219
224
|
}
|
package/src/schema/regexp.ts
CHANGED
|
@@ -20,7 +20,10 @@ export class RegexpSchema<
|
|
|
20
20
|
> extends Schema<TValue> {
|
|
21
21
|
readonly type = 'regexp' as const
|
|
22
22
|
|
|
23
|
-
constructor(
|
|
23
|
+
constructor(
|
|
24
|
+
public readonly pattern: RegExp,
|
|
25
|
+
public readonly message?: string,
|
|
26
|
+
) {
|
|
24
27
|
super()
|
|
25
28
|
}
|
|
26
29
|
|
|
@@ -30,7 +33,11 @@ export class RegexpSchema<
|
|
|
30
33
|
}
|
|
31
34
|
|
|
32
35
|
if (!this.pattern.test(input)) {
|
|
33
|
-
return ctx.issueInvalidFormat(
|
|
36
|
+
return ctx.issueInvalidFormat(
|
|
37
|
+
input,
|
|
38
|
+
this.pattern.toString(),
|
|
39
|
+
this.message,
|
|
40
|
+
)
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
return ctx.success(input as TValue)
|
|
@@ -67,6 +74,9 @@ export class RegexpSchema<
|
|
|
67
74
|
* ```
|
|
68
75
|
*/
|
|
69
76
|
/*@__NO_SIDE_EFFECTS__*/
|
|
70
|
-
export function regexp<TInput extends string = string>(
|
|
71
|
-
|
|
77
|
+
export function regexp<TInput extends string = string>(
|
|
78
|
+
pattern: RegExp,
|
|
79
|
+
message?: string,
|
|
80
|
+
): RegexpSchema<TInput> {
|
|
81
|
+
return new RegexpSchema<TInput>(pattern, message)
|
|
72
82
|
}
|
package/src/schema/string.ts
CHANGED
|
@@ -99,12 +99,15 @@ export class StringSchema<
|
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
// Optimization: count graphemes once
|
|
102
103
|
let lazyGraphLen: number
|
|
103
104
|
|
|
104
105
|
const minGraphemes = this.options.minGraphemes
|
|
105
106
|
if (minGraphemes != null) {
|
|
106
|
-
// Optimization: avoid counting graphemes if the length check already fails
|
|
107
107
|
if (str.length < minGraphemes) {
|
|
108
|
+
// If the JavaScript string length (UTF-16) is below the minimal limit,
|
|
109
|
+
// its grapheme length (which <= .length) will also be below.
|
|
110
|
+
// Fail early.
|
|
108
111
|
return ctx.issueTooSmall(str, 'grapheme', minGraphemes, str.length)
|
|
109
112
|
} else if ((lazyGraphLen ??= graphemeLen(str)) < minGraphemes) {
|
|
110
113
|
return ctx.issueTooSmall(str, 'grapheme', minGraphemes, lazyGraphLen)
|
|
@@ -113,7 +116,10 @@ export class StringSchema<
|
|
|
113
116
|
|
|
114
117
|
const maxGraphemes = this.options.maxGraphemes
|
|
115
118
|
if (maxGraphemes != null) {
|
|
116
|
-
if (
|
|
119
|
+
if (str.length <= maxGraphemes) {
|
|
120
|
+
// If the JavaScript string length (UTF-16) is within the maximum limit,
|
|
121
|
+
// its grapheme length (which <= .length) will also be within.
|
|
122
|
+
} else if ((lazyGraphLen ??= graphemeLen(str)) > maxGraphemes) {
|
|
117
123
|
return ctx.issueTooBig(str, 'grapheme', maxGraphemes, lazyGraphLen)
|
|
118
124
|
}
|
|
119
125
|
}
|
|
@@ -303,6 +303,83 @@ describe('TypedObjectSchema', () => {
|
|
|
303
303
|
const result = emptySchema.build(input)
|
|
304
304
|
expect(result).toEqual({ $type: 'app.bsky.test' })
|
|
305
305
|
})
|
|
306
|
+
|
|
307
|
+
describe('build() does not validate', () => {
|
|
308
|
+
const validationSchema = typedObject(
|
|
309
|
+
'app.bsky.test',
|
|
310
|
+
'validation',
|
|
311
|
+
object({
|
|
312
|
+
actor: string({ format: 'did' }),
|
|
313
|
+
count: integer(),
|
|
314
|
+
}),
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
it('does not throw for invalid data', () => {
|
|
318
|
+
const result = validationSchema.build({
|
|
319
|
+
// @ts-expect-error
|
|
320
|
+
actor: 'not-a-did',
|
|
321
|
+
count: 123,
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
expect(result.$type).toBe('app.bsky.test#validation')
|
|
325
|
+
expect(result.actor).toBe('not-a-did')
|
|
326
|
+
expect(result.count).toBe(123)
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
it('does not throw for invalid types', () => {
|
|
330
|
+
const result = validationSchema.build({
|
|
331
|
+
actor: 'did:plc:abc123',
|
|
332
|
+
// @ts-expect-error
|
|
333
|
+
count: 'not-a-number',
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
expect(result.$type).toBe('app.bsky.test#validation')
|
|
337
|
+
expect(result.count).toBe('not-a-number')
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
it('does not throw for missing required fields', () => {
|
|
341
|
+
// @ts-expect-error
|
|
342
|
+
const result = validationSchema.build({})
|
|
343
|
+
|
|
344
|
+
expect(result.$type).toBe('app.bsky.test#validation')
|
|
345
|
+
expect(result.actor).toBeUndefined()
|
|
346
|
+
expect(result.count).toBeUndefined()
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
it('does not throw for extra fields', () => {
|
|
350
|
+
const result = validationSchema.build({
|
|
351
|
+
actor: 'did:plc:abc123',
|
|
352
|
+
count: 42,
|
|
353
|
+
// @ts-expect-error
|
|
354
|
+
extra: 'unexpected',
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
expect(result.$type).toBe('app.bsky.test#validation')
|
|
358
|
+
// @ts-expect-error
|
|
359
|
+
expect(result.extra).toBe('unexpected')
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
it('parse() still validates after build()', () => {
|
|
363
|
+
const built = validationSchema.build({
|
|
364
|
+
// @ts-expect-error
|
|
365
|
+
actor: 'not-a-did',
|
|
366
|
+
count: 123,
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
expect(() => validationSchema.parse(built)).toThrow('Invalid DID')
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
it('safeParse() can detect validation errors after build()', () => {
|
|
373
|
+
const built = validationSchema.build({
|
|
374
|
+
// @ts-expect-error
|
|
375
|
+
actor: 'not-a-did',
|
|
376
|
+
count: 123,
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
const result = validationSchema.safeParse(built)
|
|
380
|
+
expect(result.success).toBe(false)
|
|
381
|
+
})
|
|
382
|
+
})
|
|
306
383
|
})
|
|
307
384
|
|
|
308
385
|
describe('$build method', () => {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isPlainObject } from '@atproto/lex-data'
|
|
1
|
+
import { LexMap, isPlainObject } from '@atproto/lex-data'
|
|
2
2
|
import {
|
|
3
3
|
$Type,
|
|
4
4
|
$TypeOf,
|
|
@@ -43,7 +43,7 @@ export type MaybeTypedObject<
|
|
|
43
43
|
*/
|
|
44
44
|
export class TypedObjectSchema<
|
|
45
45
|
const TType extends $Type = $Type,
|
|
46
|
-
const TShape extends Validator<
|
|
46
|
+
const TShape extends Validator<LexMap> = Validator<LexMap>,
|
|
47
47
|
> extends Schema<
|
|
48
48
|
$TypedMaybe<InferInput<TShape>, TType>,
|
|
49
49
|
$TypedMaybe<InferOutput<TShape>, TType>
|
|
@@ -74,12 +74,13 @@ export class TypedObjectSchema<
|
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
build(
|
|
77
|
-
input: Omit<
|
|
78
|
-
): $Typed<InferOutput<
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
77
|
+
input: Omit<InferOutput<TShape>, '$type'>,
|
|
78
|
+
): $Typed<InferOutput<TShape>, TType>
|
|
79
|
+
build(
|
|
80
|
+
input: Omit<InferInput<TShape>, '$type'>,
|
|
81
|
+
): $Typed<InferInput<TShape>, TType>
|
|
82
|
+
build(input: Record<string, unknown>) {
|
|
83
|
+
return $typed(input, this.$type)
|
|
83
84
|
}
|
|
84
85
|
|
|
85
86
|
isTypeOf<TValue extends Record<string, unknown>>(
|
|
@@ -155,7 +156,7 @@ export class TypedObjectSchema<
|
|
|
155
156
|
export function typedObject<
|
|
156
157
|
const N extends NsidString,
|
|
157
158
|
const H extends string,
|
|
158
|
-
const S extends Validator<
|
|
159
|
+
const S extends Validator<LexMap>,
|
|
159
160
|
>(nsid: N, hash: H, validator: S): TypedObjectSchema<$Type<N, H>, S>
|
|
160
161
|
export function typedObject<V extends { $type?: $Type }>(
|
|
161
162
|
nsid: V extends { $type?: infer T extends string }
|
|
@@ -174,7 +175,7 @@ export function typedObject<V extends { $type?: $Type }>(
|
|
|
174
175
|
export function typedObject<
|
|
175
176
|
const N extends NsidString,
|
|
176
177
|
const H extends string,
|
|
177
|
-
const S extends Validator<
|
|
178
|
+
const S extends Validator<LexMap>,
|
|
178
179
|
>(nsid: N, hash: H, validator: S) {
|
|
179
180
|
return new TypedObjectSchema<$Type<N, H>, S>($type(nsid, hash), validator)
|
|
180
181
|
}
|