@atproto/lex-schema 0.0.15 → 0.0.17

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.
Files changed (67) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/core/schema.d.ts +6 -12
  3. package/dist/core/schema.d.ts.map +1 -1
  4. package/dist/core/schema.js +11 -17
  5. package/dist/core/schema.js.map +1 -1
  6. package/dist/core/string-format.d.ts +22 -4
  7. package/dist/core/string-format.d.ts.map +1 -1
  8. package/dist/core/string-format.js +43 -19
  9. package/dist/core/string-format.js.map +1 -1
  10. package/dist/core/validation-error.d.ts +2 -2
  11. package/dist/core/validation-error.d.ts.map +1 -1
  12. package/dist/core/validation-error.js +1 -1
  13. package/dist/core/validation-error.js.map +1 -1
  14. package/dist/core/validation-issue.d.ts.map +1 -1
  15. package/dist/core/validation-issue.js +19 -38
  16. package/dist/core/validation-issue.js.map +1 -1
  17. package/dist/core/validator.d.ts +13 -1
  18. package/dist/core/validator.d.ts.map +1 -1
  19. package/dist/core/validator.js +1 -0
  20. package/dist/core/validator.js.map +1 -1
  21. package/dist/helpers.d.ts +4 -3
  22. package/dist/helpers.d.ts.map +1 -1
  23. package/dist/helpers.js +6 -1
  24. package/dist/helpers.js.map +1 -1
  25. package/dist/schema/blob.d.ts +10 -8
  26. package/dist/schema/blob.d.ts.map +1 -1
  27. package/dist/schema/blob.js +39 -14
  28. package/dist/schema/blob.js.map +1 -1
  29. package/dist/schema/payload.d.ts +2 -2
  30. package/dist/schema/payload.d.ts.map +1 -1
  31. package/dist/schema/payload.js +2 -3
  32. package/dist/schema/payload.js.map +1 -1
  33. package/dist/schema/record.d.ts +6 -8
  34. package/dist/schema/record.d.ts.map +1 -1
  35. package/dist/schema/record.js +1 -1
  36. package/dist/schema/record.js.map +1 -1
  37. package/dist/schema/regexp.d.ts +3 -2
  38. package/dist/schema/regexp.d.ts.map +1 -1
  39. package/dist/schema/regexp.js +6 -4
  40. package/dist/schema/regexp.js.map +1 -1
  41. package/dist/schema/string.d.ts.map +1 -1
  42. package/dist/schema/string.js +10 -3
  43. package/dist/schema/string.js.map +1 -1
  44. package/dist/schema/typed-object.d.ts +5 -7
  45. package/dist/schema/typed-object.d.ts.map +1 -1
  46. package/dist/schema/typed-object.js +1 -1
  47. package/dist/schema/typed-object.js.map +1 -1
  48. package/package.json +4 -3
  49. package/src/core/$type.test.ts +9 -5
  50. package/src/core/schema.ts +20 -17
  51. package/src/core/string-format.ts +62 -16
  52. package/src/core/validation-error.ts +2 -2
  53. package/src/core/validation-issue.ts +20 -36
  54. package/src/core/validator.ts +17 -1
  55. package/src/helpers.ts +7 -1
  56. package/src/schema/array.test.ts +1 -1
  57. package/src/schema/blob.test.ts +317 -49
  58. package/src/schema/blob.ts +56 -23
  59. package/src/schema/params.test.ts +2 -2
  60. package/src/schema/payload.ts +4 -5
  61. package/src/schema/record.test.ts +135 -17
  62. package/src/schema/record.ts +14 -9
  63. package/src/schema/regexp.ts +14 -4
  64. package/src/schema/string.test.ts +63 -0
  65. package/src/schema/string.ts +9 -3
  66. package/src/schema/typed-object.test.ts +77 -0
  67. package/src/schema/typed-object.ts +11 -10
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from 'vitest'
1
+ import { assert, describe, expect, it } from 'vitest'
2
2
  import { parseCid } from '@atproto/lex-data'
3
3
  import { blob } from './blob.js'
4
4
 
@@ -214,37 +214,62 @@ describe('BlobSchema', () => {
214
214
  })
215
215
 
216
216
  describe('strict validation', () => {
217
- const strictSchema = blob({ strict: true })
217
+ const schema = blob()
218
218
 
219
219
  it('accepts valid raw CID in strict mode', () => {
220
- const result = strictSchema.safeParse({
221
- $type: 'blob',
222
- ref: blobCid,
223
- mimeType: 'image/jpeg',
224
- size: 10000,
225
- })
220
+ const result = schema.safeParse(
221
+ {
222
+ $type: 'blob',
223
+ ref: blobCid,
224
+ mimeType: 'image/jpeg',
225
+ size: 10000,
226
+ },
227
+ { strict: true },
228
+ )
226
229
  expect(result.success).toBe(true)
227
230
  })
228
231
 
229
232
  it('rejects non-raw CID in strict mode', () => {
230
- const result = strictSchema.safeParse({
231
- $type: 'blob',
232
- ref: lexCid,
233
- mimeType: 'image/jpeg',
234
- size: 10000,
235
- })
233
+ const result = schema.safeParse(
234
+ {
235
+ $type: 'blob',
236
+ ref: lexCid,
237
+ mimeType: 'image/jpeg',
238
+ size: 10000,
239
+ },
240
+ { strict: true },
241
+ )
236
242
  expect(result.success).toBe(false)
237
243
  })
238
244
 
239
245
  it('accepts non-raw CID in non-strict mode', () => {
240
- const nonStrictSchema = blob({ strict: false })
241
- const result = nonStrictSchema.safeParse({
246
+ const result = schema.safeParse(
247
+ {
248
+ $type: 'blob',
249
+ ref: lexCid,
250
+ mimeType: 'image/jpeg',
251
+ size: 10000,
252
+ },
253
+ { strict: false },
254
+ )
255
+ expect(result.success).toBe(true)
256
+ })
257
+
258
+ it('coerces legacy blob format in non-strict parse mode', () => {
259
+ const result = schema.safeParse(
260
+ {
261
+ cid: lexCid.toString(),
262
+ mimeType: 'image/jpeg',
263
+ },
264
+ { strict: false },
265
+ )
266
+ assert(result.success)
267
+ expect(result.value).toEqual({
242
268
  $type: 'blob',
243
269
  ref: lexCid,
244
270
  mimeType: 'image/jpeg',
245
- size: 10000,
271
+ size: -1,
246
272
  })
247
- expect(result.success).toBe(true)
248
273
  })
249
274
  })
250
275
 
@@ -455,51 +480,294 @@ describe('BlobSchema', () => {
455
480
  })
456
481
  })
457
482
 
458
- describe('combined options', () => {
459
- it('validates with strict and allowLegacy both true', () => {
460
- const schema = blob({ strict: true, allowLegacy: true })
483
+ describe('legacy blob format with strict mode combinations', () => {
484
+ describe('allowLegacy: false (default)', () => {
485
+ const schema = blob()
486
+
487
+ describe('strict: true (default)', () => {
488
+ it('rejects legacy blob format', () => {
489
+ const result = schema.safeParse({
490
+ cid: blobCid.toString(),
491
+ mimeType: 'image/jpeg',
492
+ })
493
+ expect(result.success).toBe(false)
494
+ })
495
+
496
+ it('accepts standard BlobRef', () => {
497
+ const result = schema.safeParse({
498
+ $type: 'blob',
499
+ ref: blobCid,
500
+ mimeType: 'image/jpeg',
501
+ size: 10000,
502
+ })
503
+ expect(result.success).toBe(true)
504
+ })
505
+ })
506
+
507
+ describe('strict: false', () => {
508
+ it('coerces legacy blob format into BlobRef', () => {
509
+ const result = schema.safeParse(
510
+ {
511
+ cid: blobCid.toString(),
512
+ mimeType: 'image/jpeg',
513
+ },
514
+ { strict: false },
515
+ )
516
+ assert(result.success)
517
+ expect(result.value).toEqual({
518
+ $type: 'blob',
519
+ ref: blobCid,
520
+ mimeType: 'image/jpeg',
521
+ size: -1,
522
+ })
523
+ })
524
+
525
+ it('coerces legacy blob format with lexCid', () => {
526
+ const result = schema.safeParse(
527
+ {
528
+ cid: lexCid.toString(),
529
+ mimeType: 'image/png',
530
+ },
531
+ { strict: false },
532
+ )
533
+ assert(result.success)
534
+ expect(result.value).toEqual({
535
+ $type: 'blob',
536
+ ref: lexCid,
537
+ mimeType: 'image/png',
538
+ size: -1,
539
+ })
540
+ })
541
+
542
+ it('rejects legacy blob format with invalid cid', () => {
543
+ const result = schema.safeParse(
544
+ {
545
+ cid: 'invalid-cid',
546
+ mimeType: 'image/jpeg',
547
+ },
548
+ { strict: false },
549
+ )
550
+ expect(result.success).toBe(false)
551
+ })
552
+
553
+ it('accepts standard BlobRef with non-raw CID', () => {
554
+ const result = schema.safeParse(
555
+ {
556
+ $type: 'blob',
557
+ ref: lexCid,
558
+ mimeType: 'image/jpeg',
559
+ size: 10000,
560
+ },
561
+ { strict: false },
562
+ )
563
+ expect(result.success).toBe(true)
564
+ })
565
+ })
566
+ })
567
+
568
+ describe('allowLegacy: true', () => {
569
+ const schema = blob({ allowLegacy: true })
461
570
 
462
- // Should accept strict BlobRef
463
- const blobRefResult = schema.safeParse({
464
- $type: 'blob',
465
- ref: blobCid,
466
- mimeType: 'image/jpeg',
467
- size: 10000,
571
+ describe('strict: true (default)', () => {
572
+ it('accepts legacy blob format as LegacyBlobRef', () => {
573
+ const result = schema.safeParse({
574
+ cid: blobCid.toString(),
575
+ mimeType: 'image/jpeg',
576
+ })
577
+ assert(result.success)
578
+ expect('cid' in result.value && result.value.cid).toBe(
579
+ blobCid.toString(),
580
+ )
581
+ })
582
+
583
+ it('accepts standard BlobRef', () => {
584
+ const result = schema.safeParse({
585
+ $type: 'blob',
586
+ ref: blobCid,
587
+ mimeType: 'image/jpeg',
588
+ size: 10000,
589
+ })
590
+ expect(result.success).toBe(true)
591
+ })
592
+
593
+ it('rejects non-raw CID in BlobRef format (strict)', () => {
594
+ const result = schema.safeParse({
595
+ $type: 'blob',
596
+ ref: lexCid,
597
+ mimeType: 'image/jpeg',
598
+ size: 10000,
599
+ })
600
+ expect(result.success).toBe(false)
601
+ })
602
+ })
603
+
604
+ describe('strict: false', () => {
605
+ it('accepts legacy blob format as LegacyBlobRef', () => {
606
+ const result = schema.safeParse(
607
+ {
608
+ cid: blobCid.toString(),
609
+ mimeType: 'image/jpeg',
610
+ },
611
+ { strict: false },
612
+ )
613
+ assert(result.success)
614
+ expect('cid' in result.value && result.value.cid).toBe(
615
+ blobCid.toString(),
616
+ )
617
+ })
618
+
619
+ it('accepts standard BlobRef with non-raw CID (non-strict)', () => {
620
+ const result = schema.safeParse(
621
+ {
622
+ $type: 'blob',
623
+ ref: lexCid,
624
+ mimeType: 'image/jpeg',
625
+ size: 10000,
626
+ },
627
+ { strict: false },
628
+ )
629
+ expect(result.success).toBe(true)
630
+ })
631
+
632
+ it('accepts standard BlobRef with raw CID', () => {
633
+ const result = schema.safeParse(
634
+ {
635
+ $type: 'blob',
636
+ ref: blobCid,
637
+ mimeType: 'image/jpeg',
638
+ size: 10000,
639
+ },
640
+ { strict: false },
641
+ )
642
+ expect(result.success).toBe(true)
643
+ })
468
644
  })
469
- expect(blobRefResult.success).toBe(true)
645
+ })
646
+ })
470
647
 
471
- // Should accept LegacyBlobRef
472
- const legacyResult = schema.safeParse({
473
- cid: blobCid.toString(),
474
- mimeType: 'image/jpeg',
648
+ describe('mime and size checks depend on strict mode', () => {
649
+ describe('accept constraint', () => {
650
+ const schema = blob({ accept: ['image/jpeg', 'image/png'] })
651
+
652
+ it('rejects non-matching mime type in strict mode (default)', () => {
653
+ const result = schema.safeParse({
654
+ $type: 'blob',
655
+ ref: blobCid,
656
+ mimeType: 'image/gif',
657
+ size: 10000,
658
+ })
659
+ expect(result.success).toBe(false)
660
+ })
661
+
662
+ it('accepts non-matching mime type in non-strict mode', () => {
663
+ const result = schema.safeParse(
664
+ {
665
+ $type: 'blob',
666
+ ref: blobCid,
667
+ mimeType: 'image/gif',
668
+ size: 10000,
669
+ },
670
+ { strict: false },
671
+ )
672
+ expect(result.success).toBe(true)
475
673
  })
476
- expect(legacyResult.success).toBe(true)
477
674
 
478
- // Should reject non-raw CID in BlobRef format
479
- const nonRawResult = schema.safeParse({
480
- $type: 'blob',
481
- ref: lexCid,
482
- mimeType: 'image/jpeg',
483
- size: 10000,
675
+ it('accepts matching mime type in strict mode', () => {
676
+ const result = schema.safeParse({
677
+ $type: 'blob',
678
+ ref: blobCid,
679
+ mimeType: 'image/jpeg',
680
+ size: 10000,
681
+ })
682
+ expect(result.success).toBe(true)
484
683
  })
485
- expect(nonRawResult.success).toBe(false)
486
684
  })
487
685
 
488
- it('validates with all options combined', () => {
686
+ describe('maxSize constraint', () => {
687
+ const schema = blob({ maxSize: 1000 })
688
+
689
+ it('rejects oversized blob in strict mode (default)', () => {
690
+ const result = schema.safeParse({
691
+ $type: 'blob',
692
+ ref: blobCid,
693
+ mimeType: 'image/jpeg',
694
+ size: 5000,
695
+ })
696
+ expect(result.success).toBe(false)
697
+ })
698
+
699
+ it('accepts oversized blob in non-strict mode', () => {
700
+ const result = schema.safeParse(
701
+ {
702
+ $type: 'blob',
703
+ ref: blobCid,
704
+ mimeType: 'image/jpeg',
705
+ size: 5000,
706
+ },
707
+ { strict: false },
708
+ )
709
+ expect(result.success).toBe(true)
710
+ })
711
+
712
+ it('accepts correctly sized blob in strict mode', () => {
713
+ const result = schema.safeParse({
714
+ $type: 'blob',
715
+ ref: blobCid,
716
+ mimeType: 'image/jpeg',
717
+ size: 500,
718
+ })
719
+ expect(result.success).toBe(true)
720
+ })
721
+ })
722
+
723
+ describe('combined accept and maxSize constraints', () => {
489
724
  const schema = blob({
490
- strict: true,
491
- allowLegacy: true,
492
725
  accept: ['image/jpeg'],
493
726
  maxSize: 20000,
494
727
  })
495
728
 
496
- const result = schema.safeParse({
497
- $type: 'blob',
498
- ref: blobCid,
499
- mimeType: 'image/jpeg',
500
- size: 10000,
729
+ it('accepts valid blob in strict mode', () => {
730
+ const result = schema.safeParse({
731
+ $type: 'blob',
732
+ ref: blobCid,
733
+ mimeType: 'image/jpeg',
734
+ size: 10000,
735
+ })
736
+ expect(result.success).toBe(true)
737
+ })
738
+
739
+ it('rejects wrong mime in strict mode', () => {
740
+ const result = schema.safeParse({
741
+ $type: 'blob',
742
+ ref: blobCid,
743
+ mimeType: 'image/png',
744
+ size: 10000,
745
+ })
746
+ expect(result.success).toBe(false)
747
+ })
748
+
749
+ it('rejects oversized in strict mode', () => {
750
+ const result = schema.safeParse({
751
+ $type: 'blob',
752
+ ref: blobCid,
753
+ mimeType: 'image/jpeg',
754
+ size: 30000,
755
+ })
756
+ expect(result.success).toBe(false)
757
+ })
758
+
759
+ it('accepts wrong mime and oversized in non-strict mode', () => {
760
+ const result = schema.safeParse(
761
+ {
762
+ $type: 'blob',
763
+ ref: blobCid,
764
+ mimeType: 'video/mp4',
765
+ size: 99999,
766
+ },
767
+ { strict: false },
768
+ )
769
+ expect(result.success).toBe(true)
501
770
  })
502
- expect(result.success).toBe(true)
503
771
  })
504
772
  })
505
773
  })
@@ -1,32 +1,36 @@
1
1
  import {
2
2
  BlobRef,
3
- BlobRefCheckOptions,
4
3
  LegacyBlobRef,
5
4
  isBlobRef,
6
5
  isLegacyBlobRef,
6
+ parseCidSafe,
7
7
  } from '@atproto/lex-data'
8
8
  import { Schema, ValidationContext } from '../core.js'
9
9
  import { memoizedOptions } from '../util/memoize.js'
10
10
 
11
11
  /**
12
12
  * Configuration options for blob schema validation.
13
- *
14
- * @property allowLegacy - Whether to allow legacy blob references format
15
- * @property accept - List of accepted MIME types (supports wildcards like 'image/*' or '*\/*')
16
- * @property maxSize - Maximum blob size in bytes
17
13
  */
18
- export type BlobSchemaOptions = BlobRefCheckOptions & {
14
+ export type BlobSchemaOptions = {
19
15
  /**
20
16
  * Whether to allow legacy blob references format
17
+ *
18
+ * @default false
21
19
  * @see {@link LegacyBlobRef}
22
20
  */
23
21
  allowLegacy?: boolean
22
+
24
23
  /**
25
- * List of accepted mime types
24
+ * List of accepted MIME types (supports wildcards like 'image/*' or '*\/*')
25
+ *
26
+ * @default undefined // accepts all MIME types
26
27
  */
27
28
  accept?: string[]
29
+
28
30
  /**
29
- * Maximum size in bytes
31
+ * Maximum blob size in bytes
32
+ *
33
+ * @default undefined // no size limit
30
34
  */
31
35
  maxSize?: number
32
36
  }
@@ -61,27 +65,24 @@ export class BlobSchema<
61
65
  }
62
66
 
63
67
  validateInContext(input: unknown, ctx: ValidationContext) {
64
- const blob: null | BlobRef | LegacyBlobRef =
65
- (input as any)?.$type !== undefined
66
- ? isBlobRef(input, this.options)
67
- ? input
68
- : null
69
- : this.options?.allowLegacy === true && isLegacyBlobRef(input)
70
- ? input
71
- : null
68
+ const blob = parseValue.call(ctx, input, this.options)
72
69
 
73
70
  if (!blob) {
74
71
  return ctx.issueUnexpectedType(input, 'blob')
75
72
  }
76
73
 
77
- const accept = this.options?.accept
78
- if (accept && !matchesMime(blob.mimeType, accept)) {
79
- return ctx.issueInvalidPropertyValue(blob, 'mimeType', accept)
80
- }
74
+ // In non-strict mode, we allow blob refs to pass through without MIME
75
+ // type or size checks.
76
+ if (ctx.options.strict) {
77
+ const accept = this.options?.accept
78
+ if (accept && !matchesMime(blob.mimeType, accept)) {
79
+ return ctx.issueInvalidPropertyValue(blob, 'mimeType', accept)
80
+ }
81
81
 
82
- const maxSize = this.options?.maxSize
83
- if (maxSize != null && 'size' in blob && blob.size > maxSize) {
84
- return ctx.issueTooBig(blob, 'blob', maxSize, blob.size)
82
+ const maxSize = this.options?.maxSize
83
+ if (maxSize != null && 'size' in blob && blob.size > maxSize) {
84
+ return ctx.issueTooBig(blob, 'blob', maxSize, blob.size)
85
+ }
85
86
  }
86
87
 
87
88
  return ctx.success(blob)
@@ -94,6 +95,38 @@ export class BlobSchema<
94
95
  }
95
96
  }
96
97
 
98
+ function parseValue(
99
+ this: ValidationContext,
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
104
+ // validate accordingly.
105
+ if ((input as any)?.$type !== undefined) {
106
+ // Use the context's option for the "strict" check
107
+ return isBlobRef(input, this.options) ? input : null
108
+ }
109
+
110
+ // If there is no $type property, we may be dealing with a legacy blob ref. If
111
+ // legacy refs are allowed, validate against the legacy format. If not
112
+ // allowed, but we are in non-strict "parse" mode, coerce legacy refs into
113
+ // standard BlobRef format for backward compatibility. Otherwise, reject the
114
+ // value.
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
+ }
125
+ }
126
+
127
+ return null
128
+ }
129
+
97
130
  function matchesMime(mime: string, accepted: string[]): boolean {
98
131
  if (accepted.includes('*/*')) return true
99
132
  if (accepted.includes(mime)) return true
@@ -419,7 +419,7 @@ describe('ParamsSchema', () => {
419
419
  ['name', 'Alice'],
420
420
  ['bools', 'notabool'],
421
421
  ]),
422
- ).toThrow('Expected boolean value type (got string) at $.bools')
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 string) at $.foo.bar.bools')
434
+ ).toThrow('Expected boolean value type (got "2") at $.foo.bar.bools')
435
435
  })
436
436
 
437
437
  it('ignores empty string values', () => {
@@ -1,5 +1,5 @@
1
1
  import { LexValue } from '@atproto/lex-data'
2
- import { Infer, Schema, Validator } from '../core.js'
2
+ import { InferInput, Schema, Validator } from '../core.js'
3
3
  import { ObjectSchema, object } from './object.js'
4
4
 
5
5
  export type { LexValue }
@@ -15,7 +15,7 @@ type ToBodyType<
15
15
  TSchema,
16
16
  TBinary,
17
17
  > = TSchema extends Schema
18
- ? Infer<TSchema>
18
+ ? InferInput<TSchema>
19
19
  : TEncoding extends `application/json`
20
20
  ? LexValue
21
21
  : TBinary
@@ -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
- // Expecting no body
115
- return contentType == null
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