add lexicon schema helpers to types/bsky
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,208 @@
|
|||||||
|
import {AppBskyFeedPost} from '@atproto/api'
|
||||||
|
|
||||||
|
import {app} from '#/lexicons'
|
||||||
|
import * as bsky from '#/types/bsky'
|
||||||
|
|
||||||
|
const now = () => new Date().toISOString()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A structurally valid `app.bsky.feed.post` record.
|
||||||
|
*/
|
||||||
|
const validPost = {
|
||||||
|
$type: 'app.bsky.feed.post',
|
||||||
|
text: 'hello world',
|
||||||
|
createdAt: now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Right `$type`, but the body is invalid (`text` is not a string, `createdAt`
|
||||||
|
* is not a datetime). Passes a `$type`-only guard, fails full validation.
|
||||||
|
*/
|
||||||
|
const invalidPost = {
|
||||||
|
$type: 'app.bsky.feed.post',
|
||||||
|
text: 123,
|
||||||
|
createdAt: 'not-a-datetime',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A different record type entirely - should fail even the fast guard.
|
||||||
|
*/
|
||||||
|
const wrongType = {
|
||||||
|
$type: 'app.bsky.feed.like',
|
||||||
|
subject: {uri: 'at://x', cid: 'y'},
|
||||||
|
createdAt: now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('types/bsky lexicon schema helpers (#/lexicons)', () => {
|
||||||
|
describe('isType (fast, $type-only)', () => {
|
||||||
|
it('accepts a valid record', () => {
|
||||||
|
expect(bsky.isType(app.bsky.feed.post, validPost)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a $type mismatch', () => {
|
||||||
|
expect(bsky.isType(app.bsky.feed.post, wrongType)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts an invalid body that has the right $type (dangerous semantics)', () => {
|
||||||
|
/*
|
||||||
|
* Mirrors `dangerousIsType`: only the `$type` is checked, so a
|
||||||
|
* structurally invalid record still passes.
|
||||||
|
*/
|
||||||
|
expect(bsky.isType(app.bsky.feed.post, invalidPost)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a record with no $type at all', () => {
|
||||||
|
expect(
|
||||||
|
bsky.isType(app.bsky.feed.post, {text: 'hi', createdAt: now()}),
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts a schema passed as its namespace module or its main schema', () => {
|
||||||
|
expect(bsky.isType(app.bsky.feed.post, validPost)).toBe(true)
|
||||||
|
expect(bsky.isType(app.bsky.feed.post.main, validPost)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false (does not throw) for null, undefined and non-objects', () => {
|
||||||
|
/*
|
||||||
|
* Mirrors the `dangerousIsType`/`is$typed` behavior - call sites pass
|
||||||
|
* e.g. `post.record` which may be undefined.
|
||||||
|
*/
|
||||||
|
expect(bsky.isType(app.bsky.feed.post, null)).toBe(false)
|
||||||
|
expect(bsky.isType(app.bsky.feed.post, undefined)).toBe(false)
|
||||||
|
expect(bsky.isType(app.bsky.feed.post, 'string')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requires a present $type for typed-object def schemas', () => {
|
||||||
|
/*
|
||||||
|
* The generated TypedObjectSchema.isTypeOf treats a missing $type as a
|
||||||
|
* match (maybe-typed semantics). Our helper must NOT: when
|
||||||
|
* discriminating unions by $type, an object without $type would
|
||||||
|
* otherwise satisfy every branch.
|
||||||
|
*/
|
||||||
|
expect(bsky.isType(app.bsky.feed.defs.postView, {foo: 1})).toBe(false)
|
||||||
|
expect(bsky.isType(app.bsky.feed.defs.postView, {})).toBe(false)
|
||||||
|
expect(
|
||||||
|
bsky.isType(app.bsky.feed.defs.postView, {
|
||||||
|
$type: 'app.bsky.feed.defs#postView',
|
||||||
|
}),
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects what the schema own isTypeOf would accept or throw on', () => {
|
||||||
|
/*
|
||||||
|
* Pins the upstream behavior that justifies the hand-rolled check in
|
||||||
|
* `isType`. If either of these expectations starts failing, upstream has
|
||||||
|
* changed and `isType` can consider delegating to `isTypeOf`.
|
||||||
|
*/
|
||||||
|
expect(app.bsky.feed.defs.postView.isTypeOf({})).toBe(true)
|
||||||
|
expect(() =>
|
||||||
|
app.bsky.feed.post.main.isTypeOf(
|
||||||
|
null as unknown as {$type?: 'app.bsky.feed.post'},
|
||||||
|
),
|
||||||
|
).toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('matches (full validation guard)', () => {
|
||||||
|
it('accepts a valid record', () => {
|
||||||
|
expect(bsky.matches(app.bsky.feed.post, validPost)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts a schema passed as its namespace module or its main schema', () => {
|
||||||
|
expect(bsky.matches(app.bsky.feed.post, validPost)).toBe(true)
|
||||||
|
expect(bsky.matches(app.bsky.feed.post.main, validPost)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a $type mismatch', () => {
|
||||||
|
expect(bsky.matches(app.bsky.feed.post, wrongType)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects an invalid body even though the $type matches', () => {
|
||||||
|
expect(bsky.matches(app.bsky.feed.post, invalidPost)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects null and undefined', () => {
|
||||||
|
expect(bsky.matches(app.bsky.feed.post, null)).toBe(false)
|
||||||
|
expect(bsky.matches(app.bsky.feed.post, undefined)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('safeParse (full validation, no throw)', () => {
|
||||||
|
it('succeeds and returns the value for a valid record', () => {
|
||||||
|
const result = bsky.safeParse(app.bsky.feed.post, validPost)
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.value.text).toBe('hello world')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails with a reason for an invalid record', () => {
|
||||||
|
const result = bsky.safeParse(app.bsky.feed.post, invalidPost)
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.reason).toBeDefined()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts a schema passed as its main schema', () => {
|
||||||
|
const result = bsky.safeParse(app.bsky.feed.post.main, validPost)
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parse (full validation, throws)', () => {
|
||||||
|
it('returns the typed value for a valid record', () => {
|
||||||
|
const record = bsky.parse(app.bsky.feed.post, validPost)
|
||||||
|
expect(record.text).toBe('hello world')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws for an invalid record', () => {
|
||||||
|
expect(() => bsky.parse(app.bsky.feed.post, invalidPost)).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts a schema passed as its main schema', () => {
|
||||||
|
expect(bsky.parse(app.bsky.feed.post.main, validPost).text).toBe(
|
||||||
|
'hello world',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('types/bsky validator helpers (@atproto/api)', () => {
|
||||||
|
it('dangerousIsType accepts the right $type without validating the body', () => {
|
||||||
|
expect(
|
||||||
|
bsky.dangerousIsType<AppBskyFeedPost.Record>(
|
||||||
|
validPost,
|
||||||
|
AppBskyFeedPost.isRecord,
|
||||||
|
),
|
||||||
|
).toBe(true)
|
||||||
|
// Right $type, invalid body - still passes the fast guard.
|
||||||
|
expect(
|
||||||
|
bsky.dangerousIsType<AppBskyFeedPost.Record>(
|
||||||
|
invalidPost,
|
||||||
|
AppBskyFeedPost.isRecord,
|
||||||
|
),
|
||||||
|
).toBe(true)
|
||||||
|
expect(
|
||||||
|
bsky.dangerousIsType<AppBskyFeedPost.Record>(
|
||||||
|
wrongType,
|
||||||
|
AppBskyFeedPost.isRecord,
|
||||||
|
),
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('validate fully validates the body', () => {
|
||||||
|
expect(
|
||||||
|
bsky.validate<AppBskyFeedPost.Record>(
|
||||||
|
validPost,
|
||||||
|
AppBskyFeedPost.validateRecord,
|
||||||
|
),
|
||||||
|
).toBe(true)
|
||||||
|
expect(
|
||||||
|
bsky.validate<AppBskyFeedPost.Record>(
|
||||||
|
invalidPost,
|
||||||
|
AppBskyFeedPost.validateRecord,
|
||||||
|
),
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
+166
-2
@@ -1,4 +1,16 @@
|
|||||||
import {type ValidationResult} from '@atproto/lexicon'
|
import {
|
||||||
|
getMain,
|
||||||
|
type InferInput,
|
||||||
|
type InferOutput,
|
||||||
|
type Main,
|
||||||
|
type ParseOptions,
|
||||||
|
type RecordSchema,
|
||||||
|
type Schema,
|
||||||
|
type TypedObjectSchema,
|
||||||
|
type ValidateOptions,
|
||||||
|
type ValidationResult,
|
||||||
|
} from '@atproto/lex'
|
||||||
|
import {type ValidationResult as LegacyValidationResult} from '@atproto/lexicon'
|
||||||
|
|
||||||
export * as post from '#/types/bsky/post'
|
export * as post from '#/types/bsky/post'
|
||||||
export * as profile from '#/types/bsky/profile'
|
export * as profile from '#/types/bsky/profile'
|
||||||
@@ -45,7 +57,159 @@ export function dangerousIsType<R extends {$type?: string}>(
|
|||||||
*/
|
*/
|
||||||
export function validate<R extends {$type?: string}>(
|
export function validate<R extends {$type?: string}>(
|
||||||
record: unknown,
|
record: unknown,
|
||||||
validator: (v: unknown) => ValidationResult<R>,
|
validator: (v: unknown) => LegacyValidationResult<R>,
|
||||||
): record is R {
|
): record is R {
|
||||||
return validator(record).success
|
return validator(record).success
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Two families of helpers live in this file, distinguished by the validation
|
||||||
|
* surface they consume.
|
||||||
|
*
|
||||||
|
* `dangerousIsType` and `validate` (above) operate on the standalone
|
||||||
|
* `is*`/`validate*` functions exported by '@atproto/api' - the record and the
|
||||||
|
* validator are passed separately, e.g.
|
||||||
|
* `dangerousIsType(v, AppBskyFeedPost.isRecord)`.
|
||||||
|
*
|
||||||
|
* `isType`, `matches`, `parse`, and `safeParse` (below) operate on the
|
||||||
|
* generated lexicon schema objects from '#/lexicons'. That codegen attaches
|
||||||
|
* the validation surface directly to each schema, so these helpers take the
|
||||||
|
* schema object itself:
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* import {app} from '#/lexicons'
|
||||||
|
* import * as bsky from '#/types/bsky'
|
||||||
|
*
|
||||||
|
* bsky.isType(app.bsky.feed.post, v)
|
||||||
|
* bsky.matches(app.bsky.feed.post, v)
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Every generated namespace module (e.g. `app.bsky.feed.post`) re-exports its
|
||||||
|
* `main` schema, and bare defs (e.g. `app.bsky.feed.defs.postView`) are schema
|
||||||
|
* objects directly. Both forms are accepted here - `getMain` unwraps a module
|
||||||
|
* to its `main` schema and passes a bare schema through unchanged - so call
|
||||||
|
* sites can pass whichever is in scope.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A generated lexicon schema that carries a `$type` and therefore supports the
|
||||||
|
* fast, `$type`-only {@link isType} check: a record schema (`app.bsky.feed.post`)
|
||||||
|
* or a typed-object def schema (`app.bsky.feed.defs.postView`).
|
||||||
|
*/
|
||||||
|
type TypedSchema = RecordSchema | TypedObjectSchema
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fast type checking without full schema validation, for use with data we
|
||||||
|
* trust, or for non-critical path use cases. This only compares the `$type`
|
||||||
|
* string; it does NOT assert the rest of the object matches the schema. An
|
||||||
|
* invalid record with the right `$type` will pass.
|
||||||
|
*
|
||||||
|
* This is the '#/lexicons' equivalent of {@link dangerousIsType}. For full
|
||||||
|
* validation of the object schema, use {@link matches}, {@link parse}, or
|
||||||
|
* {@link safeParse} from this same file.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```ts
|
||||||
|
* import {app} from '#/lexicons'
|
||||||
|
* import * as bsky from '#/types/bsky'
|
||||||
|
*
|
||||||
|
* if (bsky.isType(app.bsky.feed.post, item)) {
|
||||||
|
* // `item` is narrowed to the post record type here
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function isType<S extends TypedSchema>(
|
||||||
|
schema: Main<S>,
|
||||||
|
value: unknown,
|
||||||
|
): value is InferInput<S> {
|
||||||
|
/*
|
||||||
|
* Deliberately NOT delegating to the schema's `isTypeOf`: the generated
|
||||||
|
* `TypedObjectSchema.isTypeOf` treats a MISSING `$type` as a match
|
||||||
|
* (maybe-typed semantics), which would let any plain object satisfy any
|
||||||
|
* def-schema check and break `$type`-discriminated unions. The old
|
||||||
|
* `dangerousIsType` required a present, matching `$type`, and so do we.
|
||||||
|
* The nullish/object guard also mirrors the old `is$typed` behavior of
|
||||||
|
* returning false (not throwing) for null/undefined input.
|
||||||
|
*/
|
||||||
|
return (
|
||||||
|
value != null &&
|
||||||
|
typeof value === 'object' &&
|
||||||
|
(value as {$type?: unknown}).$type === getMain(schema).$type
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fully validates the object against the schema (strict, no coercion), which
|
||||||
|
* has a performance cost, and narrows the value on success.
|
||||||
|
*
|
||||||
|
* This is the '#/lexicons' equivalent of {@link validate}. For faster checks
|
||||||
|
* with data we trust, like that from our app view, use {@link isType} from this
|
||||||
|
* same file.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```ts
|
||||||
|
* import {app} from '#/lexicons'
|
||||||
|
* import * as bsky from '#/types/bsky'
|
||||||
|
*
|
||||||
|
* if (bsky.matches(app.bsky.feed.post, item)) {
|
||||||
|
* // `item` is narrowed to the post record type here
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function matches<S extends Schema>(
|
||||||
|
schema: Main<S>,
|
||||||
|
value: unknown,
|
||||||
|
options?: ValidateOptions,
|
||||||
|
): value is InferInput<S> {
|
||||||
|
return getMain(schema).matches(value, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fully validates and parses the value against the schema, returning the typed
|
||||||
|
* value or throwing a `LexValidationError` on failure. Parsing may apply
|
||||||
|
* schema transformations such as default values.
|
||||||
|
*
|
||||||
|
* Prefer {@link safeParse} where you want to branch on failure without a
|
||||||
|
* try/catch, or {@link matches} where you only need a type guard.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```ts
|
||||||
|
* import {app} from '#/lexicons'
|
||||||
|
* import * as bsky from '#/types/bsky'
|
||||||
|
*
|
||||||
|
* const record = bsky.parse(app.bsky.feed.post, item) // typed, or throws
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function parse<S extends Schema>(
|
||||||
|
schema: Main<S>,
|
||||||
|
value: unknown,
|
||||||
|
options?: ParseOptions,
|
||||||
|
): InferOutput<S> {
|
||||||
|
return getMain(schema).parse(value, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fully validates and parses the value against the schema, returning a
|
||||||
|
* discriminated `ValidationResult` instead of throwing. Parsing may apply
|
||||||
|
* schema transformations such as default values.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```ts
|
||||||
|
* import {app} from '#/lexicons'
|
||||||
|
* import * as bsky from '#/types/bsky'
|
||||||
|
*
|
||||||
|
* const result = bsky.safeParse(app.bsky.feed.post, item)
|
||||||
|
* if (result.success) {
|
||||||
|
* // `result.value` is the typed post record
|
||||||
|
* } else {
|
||||||
|
* // `result.reason` is the LexValidationError
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function safeParse<S extends Schema>(
|
||||||
|
schema: Main<S>,
|
||||||
|
value: unknown,
|
||||||
|
options?: ParseOptions,
|
||||||
|
): ValidationResult<InferOutput<S>> {
|
||||||
|
return getMain(schema).safeParse(value, options)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user