Suite of AT Protocol TypeScript libraries built on web standards
21
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix regressions

+242 -24
+63 -24
lex/schema/params.ts
··· 9 9 type ValidatorContext, 10 10 } from "../validation.ts"; 11 11 import { type Param, type ParamScalar, paramSchema } from "./_parameters.ts"; 12 - import { StringSchema } from "./string.ts"; 13 12 14 13 export type ParamsSchemaShape = Record<string, Validator<Param | undefined>>; 15 14 ··· 88 87 const params: Record<string, Param> = {}; 89 88 90 89 for (const [key, value] of urlSearchParams.entries()) { 91 - const validator = this.validatorsMap.get(key); 92 - 93 - const coerced: ParamScalar = 94 - validator != null && validator instanceof StringSchema 95 - ? value 96 - : value === "true" 97 - ? true 98 - : value === "false" 99 - ? false 100 - : /^-?\d+$/.test(value) 101 - ? Number(value) 102 - : value; 103 - 104 90 if (params[key] === undefined) { 105 - params[key] = coerced; 91 + params[key] = value; 106 92 } else if (Array.isArray(params[key])) { 107 - (params[key] as ParamScalar[]).push(coerced); 93 + (params[key] as ParamScalar[]).push(value); 108 94 } else { 109 - params[key] = [params[key] as ParamScalar, coerced]; 95 + params[key] = [params[key] as ParamScalar, value]; 110 96 } 111 97 } 112 98 ··· 118 104 119 105 if (input !== undefined) { 120 106 for (const [key, value] of Object.entries(input)) { 121 - if (Array.isArray(value)) { 122 - for (const v of value) { 123 - urlSearchParams.append(key, String(v)); 124 - } 125 - } else if (value !== undefined) { 126 - urlSearchParams.append(key, String(value)); 127 - } 107 + const normalized = normalizeParamValue( 108 + this.validatorsMap.get(key), 109 + value, 110 + ); 111 + appendURLSearchParam(urlSearchParams, key, normalized); 128 112 } 129 113 } 130 114 131 115 return urlSearchParams; 132 116 } 133 117 } 118 + 119 + function normalizeParamValue( 120 + validator: Validator<Param | undefined> | undefined, 121 + value: unknown, 122 + ): Param | undefined { 123 + const transformed = tryParseParam(validator, value) ?? 124 + tryParseParam(paramSchema, value); 125 + if (transformed !== null) { 126 + return transformed; 127 + } 128 + 129 + if (value === undefined) { 130 + return undefined; 131 + } 132 + 133 + if (Array.isArray(value)) { 134 + return value.map(stringifyParamScalar); 135 + } 136 + 137 + return stringifyParamScalar(value); 138 + } 139 + 140 + function tryParseParam( 141 + validator: Validator<unknown> | undefined, 142 + value: unknown, 143 + ): Param | undefined | null { 144 + if (!(validator instanceof Schema)) { 145 + return null; 146 + } 147 + 148 + const result = validator.safeParse(value); 149 + return result.success ? result.value as Param | undefined : null; 150 + } 151 + 152 + function appendURLSearchParam( 153 + urlSearchParams: URLSearchParams, 154 + key: string, 155 + value: Param | undefined, 156 + ): void { 157 + if (Array.isArray(value)) { 158 + for (const item of value) { 159 + urlSearchParams.append(key, String(item)); 160 + } 161 + } else if (value !== undefined) { 162 + urlSearchParams.append(key, String(value)); 163 + } 164 + } 165 + 166 + function stringifyParamScalar(value: unknown): ParamScalar { 167 + if (typeof value === "boolean" || typeof value === "number") { 168 + return value; 169 + } 170 + 171 + return String(value); 172 + }
+36
lex/tests/params_test.ts
··· 1 + import { l } from "@atp/lex"; 2 + import { assertEquals } from "@std/assert"; 3 + 4 + Deno.test("parses wrapped string params from URLSearchParams", () => { 5 + const params = l.params({ 6 + q: l.optional(l.string()), 7 + tags: l.optional(l.array(l.string())), 8 + mode: l.optional(l.enum(["true", "false"])), 9 + }); 10 + 11 + assertEquals( 12 + params.fromURLSearchParams( 13 + new URLSearchParams("q=true&tags=123&tags=false&mode=true"), 14 + ), 15 + { 16 + q: "true", 17 + tags: ["123", "false"], 18 + mode: "true", 19 + }, 20 + ); 21 + }); 22 + 23 + Deno.test("serializes transformed params to URLSearchParams", () => { 24 + const params = l.params({ 25 + since: l.optional(l.string({ format: "datetime" })), 26 + }); 27 + 28 + assertEquals( 29 + params.toURLSearchParams( 30 + { 31 + since: new Date("2024-01-02T03:04:05.000Z"), 32 + } as unknown as never, 33 + ).toString(), 34 + "since=2024-01-02T03%3A04%3A05.000Z", 35 + ); 36 + });
+70
xrpc/client.ts
··· 318 318 return encodingHint; 319 319 } 320 320 321 + const inferredEncoding = inferEncoding(body); 322 + if ( 323 + inferredEncoding !== undefined && 324 + matchesEncoding(schemaEncoding, inferredEncoding) 325 + ) { 326 + return inferredEncoding; 327 + } 328 + 321 329 if (schemaEncoding === "*/*") { 322 330 return "application/octet-stream"; 323 331 } ··· 352 360 ); 353 361 } 354 362 363 + function inferEncoding(body: unknown): string | undefined { 364 + if ( 365 + body instanceof ArrayBuffer || 366 + ArrayBuffer.isView(body) || 367 + isReadableStreamLike(body) 368 + ) { 369 + return "application/octet-stream"; 370 + } 371 + 372 + if (isFormDataLike(body)) { 373 + return "multipart/form-data"; 374 + } 375 + 376 + if (isURLSearchParamsLike(body)) { 377 + return "application/x-www-form-urlencoded;charset=UTF-8"; 378 + } 379 + 380 + if (isBlobLike(body)) { 381 + return body.type || "application/octet-stream"; 382 + } 383 + 384 + if (typeof body === "string") { 385 + return "text/plain;charset=UTF-8"; 386 + } 387 + 388 + if (isIterable(body)) { 389 + return "application/octet-stream"; 390 + } 391 + 392 + if ( 393 + typeof body === "boolean" || 394 + typeof body === "number" || 395 + typeof body === "object" 396 + ) { 397 + return "application/json"; 398 + } 399 + 400 + return undefined; 401 + } 402 + 355 403 function matchesEncoding(pattern: string, value: string): boolean { 356 404 const normalizedPattern = normalizeEncoding(pattern); 357 405 const normalizedValue = normalizeEncoding(value); ··· 399 447 400 448 return false; 401 449 } 450 + 451 + function isReadableStreamLike(value: unknown): value is ReadableStream { 452 + return typeof ReadableStream === "function" && 453 + value instanceof ReadableStream; 454 + } 455 + 456 + function isFormDataLike(value: unknown): value is FormData { 457 + return typeof FormData === "function" && value instanceof FormData; 458 + } 459 + 460 + function isURLSearchParamsLike(value: unknown): value is URLSearchParams { 461 + return typeof URLSearchParams === "function" && 462 + value instanceof URLSearchParams; 463 + } 464 + 465 + function isIterable( 466 + value: unknown, 467 + ): value is Iterable<unknown> | AsyncIterable<unknown> { 468 + return value != null && 469 + typeof value === "object" && 470 + (Symbol.iterator in value || Symbol.asyncIterator in value); 471 + }
+73
xrpc/tests/client_test.ts
··· 23 23 assertEquals(result.data, { value: "ok" }); 24 24 }); 25 25 26 + Deno.test("serializes params using schema transforms", async () => { 27 + const method = l.query( 28 + "io.example.query", 29 + l.params({ 30 + since: l.optional(l.string({ format: "datetime" })), 31 + }), 32 + l.jsonPayload({ value: l.string() }), 33 + ); 34 + 35 + const client = new XrpcClient((url) => { 36 + assertEquals( 37 + url, 38 + "/xrpc/io.example.query?since=2024-01-02T03%3A04%3A05.000Z", 39 + ); 40 + return Promise.resolve(Response.json({ value: "ok" })); 41 + }); 42 + 43 + const result = await client.call(method, { 44 + params: { 45 + since: new Date("2024-01-02T03:04:05.000Z"), 46 + } as unknown as never, 47 + }); 48 + 49 + assertEquals(result.data, { value: "ok" }); 50 + }); 51 + 26 52 Deno.test("validates request and response when enabled", async () => { 27 53 const method = l.procedure( 28 54 "io.example.proc", ··· 78 104 79 105 assertEquals(result.data, { ok: true }); 80 106 }); 107 + 108 + Deno.test("preserves specific blob types for text wildcard payloads", async () => { 109 + const method = l.procedure( 110 + "io.example.upload", 111 + l.params(), 112 + l.payload("text/*"), 113 + l.payload(), 114 + ); 115 + 116 + const client = new XrpcClient((_url, init) => { 117 + const headers = new Headers(init.headers); 118 + assertEquals(headers.get("content-type"), "text/csv"); 119 + return Promise.resolve(new Response(null)); 120 + }); 121 + 122 + await client.call(method, { 123 + body: new Blob(["a,b\n1,2"], { type: "text/csv" }) as unknown as never, 124 + }); 125 + }); 126 + 127 + Deno.test("infers body content types for any wildcard payloads", async () => { 128 + const method = l.procedure( 129 + "io.example.upload", 130 + l.params(), 131 + l.payload("*/*"), 132 + l.payload(), 133 + ); 134 + 135 + const seen: string[] = []; 136 + const client = new XrpcClient((_url, init) => { 137 + seen.push(new Headers(init.headers).get("content-type") ?? ""); 138 + return Promise.resolve(new Response(null)); 139 + }); 140 + 141 + await client.call(method, { 142 + body: "hello", 143 + }); 144 + 145 + await client.call(method, { 146 + body: new Blob(["<p>ok</p>"], { type: "text/html" }), 147 + }); 148 + 149 + assertEquals(seen, [ 150 + "text/plain;charset=UTF-8", 151 + "text/html", 152 + ]); 153 + });