diff --git a/lex-gen/codegen/client.ts b/lex-gen/codegen/client.ts index 0ec6a9d..4acf454 100644 --- a/lex-gen/codegen/client.ts +++ b/lex-gen/codegen/client.ts @@ -70,11 +70,11 @@ const indexTs = ( gen(project, "/index.ts", (file) => { const importExtension = options?.importSuffix ?? (options?.useJsExtension ? ".js" : ".ts"); - //= import { XrpcClient, type FetchHandler, type FetchHandlerOptions } from '@atp/xrpc' + //= import { Client, type FetchHandler, type FetchHandlerOptions } from '@atp/xrpc' file.addImportDeclaration({ moduleSpecifier: "@atp/xrpc", namedImports: [ - { name: "XrpcClient" }, + { name: "Client" }, { name: "FetchHandler", isTypeOnly: true }, { name: "FetchHandlerOptions", isTypeOnly: true }, ], @@ -187,7 +187,7 @@ const indexTs = ( const clientCls = file.addClass({ name: "AtpBaseClient", isExported: true, - extends: "XrpcClient", + extends: "Client", }); for (const ns of nsidTree) { @@ -215,13 +215,13 @@ const indexTs = ( }); //= /** @deprecated use `this` instead */ - //= get xrpc(): XrpcClient { + //= get xrpc(): Client { //= return this //= } clientCls .addGetAccessor({ name: "xrpc", - returnType: "XrpcClient", + returnType: "Client", statements: ["return this"], }) .addJsDoc("@deprecated use `this` instead"); @@ -238,10 +238,10 @@ function genNamespaceCls(file: SourceFile, ns: DefTreeNode) { name: ns.className, isExported: true, }); - //= _client: XrpcClient + //= _client: Client cls.addProperty({ name: "_client", - type: "XrpcClient", + type: "Client", }); for (const userType of ns.userTypes) { @@ -267,7 +267,7 @@ function genNamespaceCls(file: SourceFile, ns: DefTreeNode) { genNamespaceCls(file, child); } - //= constructor(public client: XrpcClient) { + //= constructor(public client: Client) { //= this._client = client //= {child namespace prop declarations} //= {record prop declarations} @@ -276,7 +276,7 @@ function genNamespaceCls(file: SourceFile, ns: DefTreeNode) { parameters: [ { name: "client", - type: "XrpcClient", + type: "Client", }, ], statements: [ @@ -353,19 +353,19 @@ function genRecordCls(file: SourceFile, nsid: string, lexRecord: LexRecord) { name: `${toTitleCase(nsid)}Record`, isExported: true, }); - //= _client: XrpcClient + //= _client: Client cls.addProperty({ name: "_client", - type: "XrpcClient", + type: "Client", }); - //= constructor(client: XrpcClient) { + //= constructor(client: Client) { //= this._client = client //= } const cons = cls.addConstructor(); cons.addParameter({ name: "client", - type: "XrpcClient", + type: "Client", }); cons.setBodyText(`this._client = client`); diff --git a/xrpc-server/tests/_xrpc-client.ts b/xrpc-server/tests/_xrpc-client.ts index 7aad0bc..393018b 100644 --- a/xrpc-server/tests/_xrpc-client.ts +++ b/xrpc-server/tests/_xrpc-client.ts @@ -3,9 +3,9 @@ import type { LexiconDoc } from "@atp/lexicon"; import { type Agent, type AgentOptions, + Client as ModernClient, ResponseType, type XrpcCallOptions, - XrpcClient as ModernXrpcClient, XRPCError, XRPCInvalidResponseError, type XRPCResponse, @@ -25,12 +25,12 @@ type LexRecord = Record; export { ResponseType, XRPCError, XRPCInvalidResponseError }; -export class XrpcClient { - readonly #client: ModernXrpcClient; +export class Client { + readonly #client: ModernClient; readonly #methods: Map; constructor(agentOpts: Agent | AgentOptions, lexicons: LexiconDoc[] = []) { - this.#client = new ModernXrpcClient(agentOpts); + this.#client = new ModernClient(agentOpts); this.#methods = buildMethodMap(lexicons); } @@ -94,6 +94,8 @@ export class XrpcClient { } } +export { Client as XrpcClient }; + function buildMethodMap(lexicons: LexiconDoc[]): Map { const methods = new Map(); diff --git a/xrpc-server/tests/auth_test.ts b/xrpc-server/tests/auth_test.ts index 99f3d3c..e2ea5e3 100644 --- a/xrpc-server/tests/auth_test.ts +++ b/xrpc-server/tests/auth_test.ts @@ -1,7 +1,7 @@ import { MINUTE } from "@atp/common"; import { Secp256k1Keypair } from "@atp/crypto"; import type { LexiconDoc } from "@atp/lexicon"; -import { XrpcClient, XRPCError } from "./_xrpc-client.ts"; +import { Client, XRPCError } from "./_xrpc-client.ts"; import * as xrpcServer from "../mod.ts"; import { @@ -50,7 +50,7 @@ const LEXICONS: LexiconDoc[] = [ let server: ReturnType; let s: Deno.HttpServer; -let client: XrpcClient; +let client: Client; type AuthTestResponse = { username: string | undefined; @@ -82,7 +82,7 @@ Deno.test.beforeAll(async () => { s = await createServer(server); const port = (s as Deno.HttpServer & { port: number }).port; - client = new XrpcClient(`http://localhost:${port}`, LEXICONS); + client = new Client(`http://localhost:${port}`, LEXICONS); }); Deno.test.afterAll(async () => { diff --git a/xrpc-server/tests/bodies_test.ts b/xrpc-server/tests/bodies_test.ts index 08a8840..417f992 100644 --- a/xrpc-server/tests/bodies_test.ts +++ b/xrpc-server/tests/bodies_test.ts @@ -1,7 +1,7 @@ import { cidForCbor } from "@atp/common"; import { randomBytes } from "@atp/crypto"; import type { LexiconDoc } from "@atp/lexicon"; -import { ResponseType, XrpcClient, XRPCError } from "./_xrpc-client.ts"; +import { Client, ResponseType, XRPCError } from "./_xrpc-client.ts"; import * as xrpcServer from "../mod.ts"; import { closeServer, createServer } from "./_util.ts"; import { @@ -186,7 +186,7 @@ Deno.test({ const s = await createServer(server); const port = (s as Deno.HttpServer & { port: number }).port; const url = `http://localhost:${port}`; - const client = new XrpcClient(url, LEXICONS); + const client = new Client(url, LEXICONS); // Tests await t.step("validates input and output bodies", async () => { diff --git a/xrpc-server/tests/errors_test.ts b/xrpc-server/tests/errors_test.ts index a0fde07..791f3ba 100644 --- a/xrpc-server/tests/errors_test.ts +++ b/xrpc-server/tests/errors_test.ts @@ -1,9 +1,5 @@ import type { LexiconDoc } from "@atp/lexicon"; -import { - XrpcClient, - XRPCError, - XRPCInvalidResponseError, -} from "./_xrpc-client.ts"; +import { Client, XRPCError, XRPCInvalidResponseError } from "./_xrpc-client.ts"; import * as xrpcServer from "../mod.ts"; import { closeServer, createServer } from "./_util.ts"; import { @@ -141,11 +137,11 @@ const MISMATCHED_LEXICONS: LexiconDoc[] = [ let upstreamServer: ReturnType; let upstreamS: Deno.HttpServer; -let upstreamClient: XrpcClient; +let upstreamClient: Client; let server: ReturnType; let s: Deno.HttpServer; -let client: XrpcClient; -let badClient: XrpcClient; +let client: Client; +let badClient: Client; Deno.test.beforeAll(async () => { // Setup upstream server @@ -157,7 +153,7 @@ Deno.test.beforeAll(async () => { }); upstreamS = await createServer(upstreamServer); const upstreamPort = (upstreamS as Deno.HttpServer & { port: number }).port; - upstreamClient = new XrpcClient( + upstreamClient = new Client( `http://localhost:${upstreamPort}`, UPSTREAM_LEXICONS, ); @@ -199,8 +195,8 @@ Deno.test.beforeAll(async () => { return undefined; }); - client = new XrpcClient(`http://localhost:${port}`, LEXICONS); - badClient = new XrpcClient( + client = new Client(`http://localhost:${port}`, LEXICONS); + badClient = new Client( `http://localhost:${port}`, MISMATCHED_LEXICONS, ); diff --git a/xrpc-server/tests/ipld_test.ts b/xrpc-server/tests/ipld_test.ts index 8293911..b9e95f9 100644 --- a/xrpc-server/tests/ipld_test.ts +++ b/xrpc-server/tests/ipld_test.ts @@ -1,6 +1,6 @@ import { CID } from "multiformats/cid"; import type { LexiconDoc } from "@atp/lexicon"; -import { XrpcClient } from "./_xrpc-client.ts"; +import { Client } from "./_xrpc-client.ts"; import * as xrpcServer from "../mod.ts"; import { closeServer, createServer } from "./_util.ts"; import { assertEquals, assertExists } from "@std/assert"; @@ -47,7 +47,7 @@ const LEXICONS: LexiconDoc[] = [ let server: ReturnType; let s: Deno.HttpServer; -let client: XrpcClient; +let client: Client; Deno.test.beforeAll(async () => { server = xrpcServer.createServer(LEXICONS); @@ -75,7 +75,7 @@ Deno.test.beforeAll(async () => { ); const port = (s as Deno.HttpServer & { port: number }).port; - client = new XrpcClient(`http://localhost:${port}`, LEXICONS); + client = new Client(`http://localhost:${port}`, LEXICONS); }); Deno.test.afterAll(async () => { diff --git a/xrpc-server/tests/parameters_test.ts b/xrpc-server/tests/parameters_test.ts index 887d233..32dff0e 100644 --- a/xrpc-server/tests/parameters_test.ts +++ b/xrpc-server/tests/parameters_test.ts @@ -1,5 +1,5 @@ import type { LexiconDoc } from "@atp/lexicon"; -import { XrpcClient } from "./_xrpc-client.ts"; +import { Client } from "./_xrpc-client.ts"; import * as xrpcServer from "../mod.ts"; import { closeServer, createServer } from "./_util.ts"; import { assertEquals, assertRejects } from "@std/assert"; @@ -32,7 +32,7 @@ const LEXICONS: LexiconDoc[] = [ let server: ReturnType; let s: Deno.HttpServer; -let client: XrpcClient; +let client: Client; Deno.test.beforeAll(async () => { server = xrpcServer.createServer(LEXICONS); @@ -46,7 +46,7 @@ Deno.test.beforeAll(async () => { s = await createServer(server); const port = (s as Deno.HttpServer & { port: number }).port; - client = new XrpcClient(`http://localhost:${port}`, LEXICONS); + client = new Client(`http://localhost:${port}`, LEXICONS); }); Deno.test.afterAll(async () => { diff --git a/xrpc-server/tests/procedures_test.ts b/xrpc-server/tests/procedures_test.ts index e66c65a..6351506 100644 --- a/xrpc-server/tests/procedures_test.ts +++ b/xrpc-server/tests/procedures_test.ts @@ -1,5 +1,5 @@ import type { LexiconDoc } from "@atp/lexicon"; -import { XrpcClient } from "./_xrpc-client.ts"; +import { Client } from "./_xrpc-client.ts"; import * as xrpcServer from "../mod.ts"; import { closeServer, createServer } from "./_util.ts"; import { assertEquals } from "@std/assert"; @@ -82,7 +82,7 @@ const LEXICONS: LexiconDoc[] = [ let server: ReturnType; let s: Deno.HttpServer; -let client: XrpcClient; +let client: Client; Deno.test.beforeAll(async () => { server = xrpcServer.createServer(LEXICONS); @@ -120,7 +120,7 @@ Deno.test.beforeAll(async () => { s = await createServer(server); const port = (s as Deno.HttpServer & { port: number }).port; - client = new XrpcClient(`http://localhost:${port}`, LEXICONS); + client = new Client(`http://localhost:${port}`, LEXICONS); }); Deno.test.afterAll(async () => { diff --git a/xrpc-server/tests/queries_test.ts b/xrpc-server/tests/queries_test.ts index 2d19c74..c494c2c 100644 --- a/xrpc-server/tests/queries_test.ts +++ b/xrpc-server/tests/queries_test.ts @@ -1,5 +1,5 @@ import type { LexiconDoc } from "@atp/lexicon"; -import { XrpcClient } from "./_xrpc-client.ts"; +import { Client } from "./_xrpc-client.ts"; import * as xrpcServer from "../mod.ts"; import { closeServer, createServer } from "./_util.ts"; import { assertEquals, assertExists } from "@std/assert"; @@ -96,7 +96,7 @@ async function setupServer() { const s = await createServer(server); const port = (s as Deno.HttpServer & { port: number }).port; - const client = new XrpcClient(`http://localhost:${port}`, LEXICONS); + const client = new Client(`http://localhost:${port}`, LEXICONS); return { server: s, client }; } diff --git a/xrpc-server/tests/rate-limiter_test.ts b/xrpc-server/tests/rate-limiter_test.ts index 8acd63a..535a97a 100644 --- a/xrpc-server/tests/rate-limiter_test.ts +++ b/xrpc-server/tests/rate-limiter_test.ts @@ -1,6 +1,6 @@ import { MINUTE } from "@atp/common"; import type { LexiconDoc } from "@atp/lexicon"; -import { XrpcClient } from "./_xrpc-client.ts"; +import { Client } from "./_xrpc-client.ts"; import * as xrpcServer from "../mod.ts"; import { closeServer, createServer } from "./_util.ts"; import { assertRejects } from "@std/assert"; @@ -239,7 +239,7 @@ async function setupServer(testName: string = "test") { const s = await createServer(server); const port = (s as Deno.HttpServer & { port: number }).port; - const client = new XrpcClient(`http://localhost:${port}`, LEXICONS); + const client = new Client(`http://localhost:${port}`, LEXICONS); return { server: s, client }; } diff --git a/xrpc-server/tests/responses_test.ts b/xrpc-server/tests/responses_test.ts index 67f254c..93e1e31 100644 --- a/xrpc-server/tests/responses_test.ts +++ b/xrpc-server/tests/responses_test.ts @@ -1,6 +1,6 @@ import { byteIterableToStream } from "@atp/common"; import type { LexiconDoc } from "@atp/lexicon"; -import { XrpcClient } from "./_xrpc-client.ts"; +import { Client } from "./_xrpc-client.ts"; import * as xrpcServer from "../mod.ts"; import { closeServer, createServer } from "./_util.ts"; import { assertEquals, assertInstanceOf } from "@std/assert"; @@ -48,7 +48,7 @@ async function setupServer() { const s = await createServer(server); const port = (s as Deno.HttpServer & { port: number }).port; - const client = new XrpcClient(`http://localhost:${port}`, LEXICONS); + const client = new Client(`http://localhost:${port}`, LEXICONS); return { server: s, client }; } diff --git a/xrpc/client.ts b/xrpc/client.ts index 264c4ee..94c9573 100644 --- a/xrpc/client.ts +++ b/xrpc/client.ts @@ -1,4 +1,4 @@ -import { Procedure, type Query } from "@atp/lex"; +import { Procedure, Query } from "@atp/lex"; import { type Agent, type AgentOptions, @@ -9,9 +9,12 @@ import { type Gettable, httpResponseCodeToEnum, ResponseType, + type XrpcCallCompatibleOptions, type XrpcCallOptions, XRPCError, XRPCInvalidResponseError, + type XrpcMethod, + type XrpcMethodLike, XRPCResponse, } from "./types.ts"; import { @@ -22,9 +25,7 @@ import { } from "./util.ts"; import type { DidString } from "@atp/lex"; -type XrpcMethod = Query | Procedure; - -export class XrpcClient { +export class Client { readonly agent: Agent; readonly fetchHandler: FetchHandler; readonly headers: Map> = new Map< @@ -55,10 +56,18 @@ export class XrpcClient { this.headers.clear(); } - async call( - method: M, + call( + input: M, + ): Promise; + call( + input: M, + options: O & XrpcCallCompatibleOptions, + ): Promise; + async call( + input: M, options: XrpcCallOptions = {} as XrpcCallOptions, ): Promise { + const method = getXrpcMethod(input); const params = this.getValidatedParams(method, options); const reqUrl = this.constructMethodCallUrl(method, params); const reqHeaders = this.constructMethodCallHeaders(method, options); @@ -267,6 +276,24 @@ export class XrpcClient { } } +export { Client as XrpcClient }; + +function getXrpcMethod(input: XrpcMethodLike): XrpcMethod { + if (isXrpcMethod(input)) { + return input; + } + + if ("main" in input && isXrpcMethod(input.main)) { + return input.main; + } + + throw new TypeError("Expected an XRPC method or a namespace with main"); +} + +function isXrpcMethod(value: unknown): value is XrpcMethod { + return value instanceof Query || value instanceof Procedure; +} + function resolveProcedurePayload( schemaEncoding: string | undefined, body: unknown, diff --git a/xrpc/mod.ts b/xrpc/mod.ts index 3c52182..b2f1c30 100644 --- a/xrpc/mod.ts +++ b/xrpc/mod.ts @@ -7,7 +7,7 @@ * @example Fetching an XRPC endpoint * ```typescript * import { LexiconDoc } from '@atp/lexicon' - * import { XrpcClient } from '@atp/xrpc' + * import { Client } from '@atp/xrpc' * * const pingLexicon = { * lexicon: 1, @@ -32,11 +32,11 @@ * }, * } satisfies LexiconDoc * - * const xrpc = new XrpcClient('https://ping.example.com', [ + * const client = new Client('https://ping.example.com', [ * pingLexicon, * ]) * - * const res1 = await xrpc.call('io.example.ping', { + * const res1 = await client.call('io.example.ping', { * message: 'hello world', * }) * res1.body // => {message: 'hello world'} diff --git a/xrpc/tests/client_test.ts b/xrpc/tests/client_test.ts index 0f49e49..f6bfba7 100644 --- a/xrpc/tests/client_test.ts +++ b/xrpc/tests/client_test.ts @@ -1,8 +1,12 @@ import { l } from "@atp/lex"; import { assertEquals, assertRejects } from "@std/assert"; -import { XrpcClient } from "../mod.ts"; +import { Client } from "../mod.ts"; +import type { XrpcCallCompatibleOptions } from "../types.ts"; import { XRPCError, XRPCInvalidResponseError } from "../types.ts"; +type Expect = T; +type IsNever = [T] extends [never] ? true : false; + Deno.test("calls query with lex method and params", async () => { const method = l.query( "io.example.query", @@ -10,7 +14,7 @@ Deno.test("calls query with lex method and params", async () => { l.jsonPayload({ value: l.string() }), ); - const client = new XrpcClient((url, init) => { + const client = new Client((url, init) => { assertEquals(url, "/xrpc/io.example.query?limit=7"); assertEquals(init.method, "get"); return Promise.resolve(Response.json({ value: "ok" })); @@ -32,7 +36,7 @@ Deno.test("serializes params using schema transforms", async () => { l.jsonPayload({ value: l.string() }), ); - const client = new XrpcClient((url) => { + const client = new Client((url) => { assertEquals( url, "/xrpc/io.example.query?since=2024-01-02T03%3A04%3A05.000Z", @@ -49,6 +53,123 @@ Deno.test("serializes params using schema transforms", async () => { assertEquals(result.data, { value: "ok" }); }); +Deno.test("accepts plain strings for formatted query params", async () => { + const method = l.query( + "io.example.getRecord", + l.params({ + repo: l.string({ format: "at-identifier" }), + collection: l.string({ format: "nsid" }), + rkey: l.string({ format: "record-key" }), + uri: l.optional(l.string({ format: "uri" })), + }), + l.payload(), + ); + + const client = new Client((url, init) => { + assertEquals( + url, + "/xrpc/io.example.getRecord?repo=did%3Aplc%3A6hbqm2oftpotwuw7gvvrui3i&collection=app.bsky.feed.post&rkey=3mjlhmszzo22h&uri=https%3A%2F%2Fexample.com%2Fpost%2F1", + ); + assertEquals(init.method, "get"); + return Promise.resolve(new Response(null)); + }); + + await client.call(method, { + params: { + repo: "did:plc:6hbqm2oftpotwuw7gvvrui3i", + collection: "app.bsky.feed.post", + rkey: "3mjlhmszzo22h", + uri: "https://example.com/post/1", + }, + }); +}); + +Deno.test("only matching string literals satisfy formatted params", () => { + const method = l.query( + "io.example.getRecord", + l.params({ + repo: l.string({ format: "at-identifier" }), + collection: l.string({ format: "nsid" }), + rkey: l.string({ format: "record-key" }), + }), + l.payload(), + ); + + type Valid = XrpcCallCompatibleOptions; + type InvalidRepo = XrpcCallCompatibleOptions; + type GenericRepo = XrpcCallCompatibleOptions; + + type ValidParams = NonNullable; + type InvalidRepoParams = NonNullable; + type GenericRepoParams = NonNullable; + + type _validRepo = Expect< + IsNever extends false ? true : false + >; + type _invalidRepo = Expect>; + type _genericRepo = Expect>; +}); + +Deno.test("calls query with namespace main export", async () => { + const main = l.query( + "io.example.query", + l.params({ limit: l.optional(l.integer()) }), + l.jsonPayload({ value: l.string() }), + ); + const namespace = { main } as const; + + const client = new Client((url, init) => { + assertEquals(url, "/xrpc/io.example.query?limit=3"); + assertEquals(init.method, "get"); + return Promise.resolve(Response.json({ value: "ok" })); + }); + + const result = await client.call(namespace, { + params: { limit: 3 }, + }); + + assertEquals(result.data, { value: "ok" }); +}); + +Deno.test("calls query with namespace Main export", async () => { + const Main = l.query( + "io.example.query", + l.params({ limit: l.optional(l.integer()) }), + l.jsonPayload({ value: l.string() }), + ); + const namespace = { Main } as const; + + const client = new Client((url, init) => { + assertEquals(url, "/xrpc/io.example.query?limit=5"); + assertEquals(init.method, "get"); + return Promise.resolve(Response.json({ value: "ok" })); + }); + + const result = await client.call(namespace, { + params: { limit: 5 }, + }); + + assertEquals(result.data, { value: "ok" }); +}); + Deno.test("validates request and response when enabled", async () => { const method = l.procedure( "io.example.proc", @@ -57,9 +178,7 @@ Deno.test("validates request and response when enabled", async () => { l.jsonPayload({ id: l.string() }), ); - const client = new XrpcClient(() => - Promise.resolve(Response.json({ id: 123 })) - ); + const client = new Client(() => Promise.resolve(Response.json({ id: 123 }))); await assertRejects( async () => { @@ -82,6 +201,83 @@ Deno.test("validates request and response when enabled", async () => { ); }); +Deno.test("accepts formatted strings in json request bodies", async () => { + const method = l.procedure( + "io.example.proc", + l.params(), + l.jsonPayload({ + repo: l.string({ format: "at-identifier" }), + rkey: l.string({ format: "record-key" }), + createdAt: l.string({ format: "datetime" }), + }), + l.payload(), + ); + + const client = new Client((_url, init) => { + assertEquals(init.method, "post"); + assertEquals( + new Headers(init.headers).get("content-type"), + "application/json", + ); + return Promise.resolve(new Response(null)); + }); + + await client.call(method, { + body: { + repo: "did:plc:6hbqm2oftpotwuw7gvvrui3i", + rkey: "3mjlhmszzo22h", + createdAt: "2024-01-02T03:04:05.000Z", + }, + }); +}); + +Deno.test("only matching string literals satisfy formatted json bodies", () => { + const method = l.procedure( + "io.example.proc", + l.params(), + l.jsonPayload({ + createdAt: l.string({ format: "datetime" }), + uri: l.string({ format: "at-uri" }), + cid: l.string({ format: "cid" }), + }), + l.payload(), + ); + + type Valid = XrpcCallCompatibleOptions; + type InvalidDatetime = XrpcCallCompatibleOptions; + type InvalidUri = XrpcCallCompatibleOptions; + + type ValidBody = NonNullable; + type InvalidDatetimeBody = NonNullable; + type InvalidUriBody = NonNullable; + + type _validBody = Expect< + IsNever extends false ? true : false + >; + type _invalidDatetime = Expect>; + type _invalidUri = Expect>; +}); + Deno.test("uses method encoding defaults for wildcard payloads", async () => { const method = l.procedure( "io.example.upload", @@ -90,7 +286,7 @@ Deno.test("uses method encoding defaults for wildcard payloads", async () => { l.jsonPayload({ ok: l.boolean() }), ); - const client = new XrpcClient((_url, init) => { + const client = new Client((_url, init) => { const headers = new Headers(init.headers); assertEquals(headers.get("content-type"), "image/png"); assertEquals(init.method, "post"); @@ -113,7 +309,7 @@ Deno.test("preserves specific blob types for text wildcard payloads", async () = l.payload(), ); - const client = new XrpcClient((_url, init) => { + const client = new Client((_url, init) => { const headers = new Headers(init.headers); assertEquals(headers.get("content-type"), "text/csv"); return Promise.resolve(new Response(null)); @@ -133,7 +329,7 @@ Deno.test("infers body content types for any wildcard payloads", async () => { ); const seen: string[] = []; - const client = new XrpcClient((_url, init) => { + const client = new Client((_url, init) => { seen.push(new Headers(init.headers).get("content-type") ?? ""); return Promise.resolve(new Response(null)); }); diff --git a/xrpc/types.ts b/xrpc/types.ts index 4c000f7..0743f7c 100644 --- a/xrpc/types.ts +++ b/xrpc/types.ts @@ -1,13 +1,359 @@ import { z } from "zod"; import type { + AtIdentifierString, + AtUriString, + CidString, + DatetimeString, + DidString, + HandleString, InferMethodInputBody, InferMethodParams, Procedure, Query, + RecordKeyString, + TidString, } from "@atp/lex"; export type QueryParams = Record; export type HeadersMap = Record; +export type XrpcMethod = Query | Procedure; +export type XrpcMethodNamespace = + | { readonly main: M } + | { readonly Main: M }; +export type XrpcMethodLike = + | M + | XrpcMethodNamespace; + +type InferXrpcMethod = M extends XrpcMethod ? M + : M extends { readonly main: infer Inner } ? Inner extends XrpcMethod ? Inner + : never + : M extends { readonly Main: infer Inner } ? Inner extends XrpcMethod ? Inner + : never + : never; + +type Digit = + | "0" + | "1" + | "2" + | "3" + | "4" + | "5" + | "6" + | "7" + | "8" + | "9"; +type LowerAlpha = + | "a" + | "b" + | "c" + | "d" + | "e" + | "f" + | "g" + | "h" + | "i" + | "j" + | "k" + | "l" + | "m" + | "n" + | "o" + | "p" + | "q" + | "r" + | "s" + | "t" + | "u" + | "v" + | "w" + | "x" + | "y" + | "z"; +type UpperAlpha = Uppercase; +type Alpha = LowerAlpha | UpperAlpha; +type DidChar = Alpha | Digit | "." | "_" | ":" | "%" | "-"; +type DidEndChar = Alpha | Digit | "." | "_" | "-"; +type HandleChar = Alpha | Digit | "-"; +type RecordKeyChar = Alpha | Digit | "_" | "~" | "." | ":" | "-"; +type TidInitialChar = + | "2" + | "3" + | "4" + | "5" + | "6" + | "7" + | "a" + | "b" + | "c" + | "d" + | "e" + | "f" + | "g" + | "h" + | "i" + | "j"; +type TidChar = + | "2" + | "3" + | "4" + | "5" + | "6" + | "7" + | LowerAlpha; +type Base32Char = LowerAlpha | "2" | "3" | "4" | "5" | "6" | "7"; +type Base58Char = + | "1" + | "2" + | "3" + | "4" + | "5" + | "6" + | "7" + | "8" + | "9" + | "A" + | "B" + | "C" + | "D" + | "E" + | "F" + | "G" + | "H" + | "J" + | "K" + | "L" + | "M" + | "N" + | "P" + | "Q" + | "R" + | "S" + | "T" + | "U" + | "V" + | "W" + | "X" + | "Y" + | "Z" + | "a" + | "b" + | "c" + | "d" + | "e" + | "f" + | "g" + | "h" + | "i" + | "j" + | "k" + | "m" + | "n" + | "o" + | "p" + | "q" + | "r" + | "s" + | "t" + | "u" + | "v" + | "w" + | "x" + | "y" + | "z"; + +type IsLiteralString = string extends S ? false : true; + +type IsChars = + IsLiteralString extends false ? false + : S extends "" ? true + : S extends `${infer First}${infer Rest}` + ? First extends Allowed ? IsChars + : false + : false; + +type IsDidMethod = IsLiteralString extends false ? false + : S extends `${infer First}${infer Rest}` + ? First extends LowerAlpha + ? Rest extends "" ? true : IsChars + : false + : false; + +type IsDidIdentifier = IsLiteralString extends false + ? false + : S extends `${infer First}${infer Rest}` + ? Rest extends "" ? First extends DidEndChar ? true : false + : First extends DidChar ? IsDidIdentifier + : false + : false; + +type IsDidLiteral = S extends + `did:${infer Method}:${infer Id}` + ? IsDidMethod extends true ? IsDidIdentifier + : false + : false; + +type IsHandleLabel = IsLiteralString extends false ? false + : S extends `${infer First}${infer Rest}` + ? First extends Alpha | Digit + ? Rest extends "" ? true : IsHandleLabelTail + : false + : false; + +type IsHandleLabelTail = S extends + `${infer First}${infer Rest}` + ? Rest extends "" ? First extends Alpha | Digit ? true : false + : First extends HandleChar ? IsHandleLabelTail + : false + : false; + +type IsFinalHandleLabel = IsLiteralString extends false + ? false + : S extends `${infer First}${infer Rest}` + ? First extends Alpha ? Rest extends "" ? true : IsHandleLabelTail + : false + : false; + +type IsHandleParts = S extends `${infer Label}.${infer Rest}` + ? IsHandleLabel