diff --git a/lex/core/$type.ts b/lex/core/$type.ts index 5044396..412a69c 100644 --- a/lex/core/$type.ts +++ b/lex/core/$type.ts @@ -1,3 +1,4 @@ +import type { OmitKey, Simplify } from "./types.ts"; import type { NsidString } from "./string-format.ts"; export type $Type< @@ -16,3 +17,32 @@ export function $type( ): $Type { return (hash === "main" ? nsid : `${nsid}#${hash}`) as $Type; } + +export type $Typed = Simplify< + V & { + $type: T; + } +>; + +export function $typed, T extends string>( + value: V, + $type: T, +): $Typed, T> { + return (value as { $type?: unknown }).$type === $type + ? value as unknown as $Typed, T> + : { ...value, $type }; +} + +export type $TypedMaybe = Simplify< + V & { + $type?: T; + } +>; + +export type Un$Typed = OmitKey; + +declare const unknown$TypeSymbol: unique symbol; + +export type Unknown$Type = string & { [unknown$TypeSymbol]: true }; + +export type Unknown$TypedObject = { $type: Unknown$Type }; diff --git a/lex/core/types.ts b/lex/core/types.ts index 18ecd3a..1d260ae 100644 --- a/lex/core/types.ts +++ b/lex/core/types.ts @@ -2,6 +2,10 @@ export type UnknownString = string & NonNullable; export type Simplify = { [K in keyof T]: T[K] } & NonNullable; +export type OmitKey = { + [K2 in keyof T as K2 extends K ? never : K2]: T[K2]; +}; + declare const __restricted: unique symbol; export type Restricted = typeof __restricted & { [__restricted]: Message; diff --git a/lex/schema/typed-object.ts b/lex/schema/typed-object.ts index cf348e9..8374574 100644 --- a/lex/schema/typed-object.ts +++ b/lex/schema/typed-object.ts @@ -1,5 +1,11 @@ import { isPlainObject } from "../data/object.ts"; -import type { $Type, Simplify } from "../core.ts"; +import type { + $Type, + $Typed, + $TypedMaybe, + Un$Typed, +} from "../core.ts"; +import { $typed } from "../core.ts"; import { type Infer, Schema, @@ -8,10 +14,16 @@ import { type ValidatorContext, } from "../validation.ts"; +export type MaybeTypedObject< + T extends $Type, + V extends { $type?: unknown } = { $type?: unknown }, +> = V extends { $type?: T } ? V + : $TypedMaybe; + export type TypedObjectSchemaOutput< T extends $Type, S extends Validator<{ [_ in string]?: unknown }>, -> = Simplify & { $type?: T }>; +> = $TypedMaybe, T>; export class TypedObjectSchema< const T extends $Type = any, @@ -26,25 +38,25 @@ export class TypedObjectSchema< isTypeOf>( value: X, - ): value is X extends { $type?: T } ? X : X & { $type?: T } { + ): value is MaybeTypedObject { return value.$type === undefined || value.$type === this.$type; } build, "$type">>( input: X, - ): Simplify & { $type: T }> { - return { ...input, $type: this.$type }; + ): $Typed, T> { + return $typed(input, this.$type); } $isTypeOf>( value: X, - ): value is X extends { $type?: T } ? X : X & { $type?: T } { + ): value is MaybeTypedObject { return this.isTypeOf(value); } $build, "$type">>( input: X, - ): Simplify & { $type: T }> { + ): $Typed, T> { return this.build(input); } diff --git a/lex/schema/typed-ref.ts b/lex/schema/typed-ref.ts index 3536f6b..52ba477 100644 --- a/lex/schema/typed-ref.ts +++ b/lex/schema/typed-ref.ts @@ -1,3 +1,4 @@ +import type { $Typed } from "../core.ts"; import { Schema, type ValidationResult, @@ -14,7 +15,7 @@ export type TypedRefGetter = () => TypedRefSchemaValidator; export type TypedRefSchemaOutput = V extends - { $type?: infer T extends string } ? V & { $type: T } : never; + { $type?: infer T extends string } ? $Typed : never; export class TypedRefSchema extends Schema< TypedRefSchemaOutput diff --git a/lex/schema/typed-union.ts b/lex/schema/typed-union.ts index b9df869..b0c2045 100644 --- a/lex/schema/typed-union.ts +++ b/lex/schema/typed-union.ts @@ -1,8 +1,11 @@ +import { isCid } from "../data/cid.ts"; import { isPlainObject } from "../data/object.ts"; -import type { Restricted, UnknownString } from "../core/types.ts"; +import type { Unknown$TypedObject } from "../core.ts"; import { lazyProperty } from "../util/lazy-property.ts"; import { type Infer, + IssueInvalidType, + type PropertyKey, Schema, type ValidationResult, type ValidatorContext, @@ -11,11 +14,7 @@ import type { TypedRefSchema, TypedRefSchemaOutput } from "./typed-ref.ts"; export type TypedRef = TypedRefSchemaOutput; -export type TypedObject = - & { $type: UnknownString } - & { - [K in string]: Restricted<"Unknown property">; - }; +export type TypedObject = Unknown$TypedObject; type TypedRefSchemasToUnion = { [K in keyof T]: Infer; @@ -27,6 +26,17 @@ export type TypedUnionSchemaOutput< > = Closed extends true ? TypedRefSchemasToUnion : TypedRefSchemasToUnion | TypedObject; +const LEX_VALUE_TYPES = [ + "integer", + "string", + "boolean", + "null", + "array", + "object", + "bytes", + "cid", +] as const; + export class TypedUnionSchema< TypedRefs extends readonly TypedRefSchema[] = any, Closed extends boolean = any, @@ -73,8 +83,56 @@ export class TypedUnionSchema< return ctx.issueInvalidPropertyType(input, "$type", "string"); } + const invalidLexValue = findInvalidLexValue(input); + if (invalidLexValue) { + return ctx.failure( + new IssueInvalidType( + ctx.concatPath(invalidLexValue.path), + invalidLexValue.value, + LEX_VALUE_TYPES, + ), + ); + } + return ctx.success( input as TypedUnionSchemaOutput, ); } } + +function findInvalidLexValue( + value: unknown, + path: PropertyKey[] = [], +): { path: PropertyKey[]; value: unknown } | undefined { + switch (typeof value) { + case "number": + return Number.isInteger(value) ? undefined : { path, value }; + case "string": + case "boolean": + return undefined; + case "object": + if (value === null || value instanceof Uint8Array || isCid(value)) { + return undefined; + } + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + const invalid = findInvalidLexValue(value[i], path.concat(i)); + if (invalid) return invalid; + } + return undefined; + } + if (isPlainObject(value)) { + for (const key in value as Record) { + const invalid = findInvalidLexValue( + (value as Record)[key], + path.concat(key), + ); + if (invalid) return invalid; + } + return undefined; + } + return { path, value }; + default: + return { path, value }; + } +} diff --git a/lex/tests/typed-utils_test.ts b/lex/tests/typed-utils_test.ts new file mode 100644 index 0000000..c1b66de --- /dev/null +++ b/lex/tests/typed-utils_test.ts @@ -0,0 +1,141 @@ +import { assert, assertEquals, assertStrictEquals } from "@std/assert"; +import { l } from "@atp/lex"; +import type { LexMap } from "@atp/lex/data"; + +const unknownTypedObject: l.Unknown$TypedObject = { + $type: "com.example.unknown" as l.Unknown$Type, +}; + +const lexMap: LexMap = unknownTypedObject; +const nestedLexMap: LexMap = { + arr: [unknownTypedObject], + val: unknownTypedObject, +}; + +Deno.test("$typed adds $type when missing", () => { + assertEquals( + l.$typed({ text: "hello" }, "com.example.post"), + { + $type: "com.example.post", + text: "hello", + }, + ); +}); + +Deno.test("$typed reuses typed values with the same $type", () => { + const value: l.$Typed<{ text: string }, "com.example.post"> = { + $type: "com.example.post", + text: "hello", + }; + + assertStrictEquals(l.$typed(value, "com.example.post"), value); +}); + +Deno.test("$typed retags typed values without collapsing the result type", () => { + const value: l.$Typed<{ text: string }, "com.example.old"> = { + $type: "com.example.old", + text: "hello", + }; + + const retagged: l.$Typed<{ text: string }, "com.example.new"> = l.$typed( + value, + "com.example.new", + ); + + assertEquals(retagged, { + $type: "com.example.new", + text: "hello", + }); +}); + +Deno.test("Un$Typed removes $type at the type level", () => { + const value: l.Un$Typed> = { + text: "hello", + }; + + assertEquals(value, { text: "hello" }); +}); + +Deno.test("$TypedMaybe allows omitting $type", () => { + const value: l.$TypedMaybe<{ text: string }, "com.example.post"> = { + text: "hello", + }; + + assertEquals(value, { text: "hello" }); +}); + +Deno.test("Unknown$TypedObject is assignable to LexMap", () => { + assertEquals(lexMap, unknownTypedObject); + assertEquals(nestedLexMap, { + arr: [unknownTypedObject], + val: unknownTypedObject, + }); +}); + +Deno.test("typedObject isTypeOf narrows Unknown$TypedObject", () => { + const known = l.typedObject( + "com.example.post", + "main", + l.object({ + text: l.string(), + }), + ); + const value: l.Unknown$TypedObject = { + $type: "com.example.post" as l.Unknown$Type, + }; + + if (known.isTypeOf(value)) { + const narrowed: { $type?: "com.example.post" } = value; + assertEquals(narrowed.$type, "com.example.post"); + return; + } + + assert(false); +}); + +Deno.test("open typed unions accept unknown payloads that are LexMap values", () => { + const known = l.typedObject( + "com.example.post", + "main", + l.object({ + text: l.string(), + }), + ); + const union = l.typedUnion([l.typedRef(() => known)], false); + const parsed = union.parse({ + $type: "com.example.unknown", + nested: { + text: "hello", + }, + }); + + assertEquals( + parsed as unknown, + { + $type: "com.example.unknown", + nested: { + text: "hello", + }, + }, + ); +}); + +Deno.test("open typed unions reject unknown payloads with non-LexMap values", () => { + const known = l.typedObject( + "com.example.post", + "main", + l.object({ + text: l.string(), + }), + ); + const union = l.typedUnion([l.typedRef(() => known)], false); + const result = union.safeParse({ + $type: "com.example.unknown", + nested: { + bad: () => "nope", + }, + }); + + assert(!result.success); + assertEquals(result.error.issues[0]?.path, ["nested", "bad"]); +}); diff --git a/xrpc-server/server.ts b/xrpc-server/server.ts index 1d5a5ec..99541f5 100644 --- a/xrpc-server/server.ts +++ b/xrpc-server/server.ts @@ -79,10 +79,13 @@ import { type XrpcMux, } from "./stream/adapters.ts"; -type LexAddConfig = M extends - Procedure | Query ? LexMethodConfig | LexMethodHandler +type LexAddConfig< + M extends Procedure | Query | Subscription, + A extends Auth = Auth, +> = M extends Procedure | Query + ? LexMethodConfig | LexMethodHandler : M extends Subscription - ? LexSubscriptionConfig | LexSubscriptionHandler + ? LexSubscriptionConfig | LexSubscriptionHandler : never; /** @@ -213,9 +216,9 @@ export class Server { // handlers - add( + add( method: LexMethodLike, - configOrHandler: LexAddConfig, + configOrHandler: LexAddConfig, ): void { const schema = getLexMethod(method); const config = typeof configOrHandler === "function" @@ -225,20 +228,20 @@ export class Server { if (schema instanceof Procedure) { return this.addProcedureSchema( schema, - config as LexMethodConfig, + config as LexMethodConfig, ); } if (schema instanceof Query) { return this.addQuerySchema( schema, - config as LexMethodConfig, + config as LexMethodConfig, ); } return this.addSubscriptionSchema( schema, - config as LexSubscriptionConfig, + config as LexSubscriptionConfig, ); } diff --git a/xrpc-server/tests/lex_compat_test.ts b/xrpc-server/tests/lex_compat_test.ts index d5f3ad3..9370a62 100644 --- a/xrpc-server/tests/lex_compat_test.ts +++ b/xrpc-server/tests/lex_compat_test.ts @@ -121,6 +121,14 @@ const defaultedQuery = l.query( }), ); +const inferredAuthQuery = l.query( + "io.example.inferredAuthQuery", + l.params(), + l.jsonPayload({ + authenticated: l.boolean(), + }), +); + let server: xrpcServer.Server; let httpServer: Deno.HttpServer; let client: Client; @@ -187,6 +195,16 @@ Deno.test.beforeAll(async () => { }), }); + server.add(inferredAuthQuery, { + auth: () => ({ + credentials: { type: "custom" as const }, + }), + handler: ({ auth }) => ({ + encoding: "application/json", + body: { authenticated: auth.credentials.type === "custom" }, + }), + }); + httpServer = await createServer(server); const port = (httpServer as Deno.HttpServer & { port: number }).port; baseUrl = `http://localhost:${port}`; @@ -319,6 +337,15 @@ Deno.test("applies parsed lex sdk response bodies", { assertEquals(response.data, { message: "hello default" }); }); +Deno.test("infers auth types for lex sdk methods", { + sanitizeOps: false, + sanitizeResources: false, +}, async () => { + const response = await client.call(inferredAuthQuery); + + assertEquals(response.data, { authenticated: true }); +}); + Deno.test("registers subscriptions from lex sdk methods", { sanitizeOps: false, sanitizeResources: false,