mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-02 16:20:36 -05:00
zod -> valibot (#3364)
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import {
|
||||
TIER_1_ID,
|
||||
TIER_2_ID,
|
||||
@@ -7,68 +7,74 @@ import {
|
||||
UNKNOWN_TIER_ID,
|
||||
} from "./constants";
|
||||
|
||||
export const patreonRateLimitSchema = z.object({
|
||||
errors: z.array(
|
||||
z.object({
|
||||
retry_after_seconds: z.number().optional(),
|
||||
export const patreonRateLimitSchema = v.object({
|
||||
errors: v.array(
|
||||
v.object({
|
||||
retry_after_seconds: v.optional(v.number()),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const patronResponseSchema = z.object({
|
||||
data: z.array(
|
||||
z.object({
|
||||
attributes: z.object({
|
||||
pledge_relationship_start: z.string().nullish(),
|
||||
export const patronResponseSchema = v.object({
|
||||
data: v.array(
|
||||
v.object({
|
||||
attributes: v.object({
|
||||
pledge_relationship_start: v.nullish(v.string()),
|
||||
}),
|
||||
id: z.string(),
|
||||
relationships: z.object({
|
||||
currently_entitled_tiers: z.object({
|
||||
data: z.array(
|
||||
z.object({
|
||||
id: z.enum([
|
||||
id: v.string(),
|
||||
relationships: v.object({
|
||||
currently_entitled_tiers: v.object({
|
||||
data: v.array(
|
||||
v.object({
|
||||
id: v.picklist([
|
||||
TIER_1_ID,
|
||||
TIER_2_ID,
|
||||
TIER_3_ID,
|
||||
TIER_4_ID,
|
||||
UNKNOWN_TIER_ID,
|
||||
]),
|
||||
type: z.string(),
|
||||
type: v.string(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
user: z.object({
|
||||
data: z.object({ id: z.string(), type: z.string() }),
|
||||
links: z.object({ related: z.string() }),
|
||||
user: v.object({
|
||||
data: v.object({ id: v.string(), type: v.string() }),
|
||||
links: v.object({ related: v.string() }),
|
||||
}),
|
||||
}),
|
||||
type: z.string(),
|
||||
type: v.string(),
|
||||
}),
|
||||
),
|
||||
included: z
|
||||
.array(
|
||||
z.object({
|
||||
attributes: z.object({
|
||||
social_connections: z
|
||||
.object({
|
||||
discord: z
|
||||
.object({
|
||||
user_id: z.string(),
|
||||
})
|
||||
.nullish(),
|
||||
})
|
||||
.nullish(),
|
||||
included: v.optional(
|
||||
v.nullable(
|
||||
v.array(
|
||||
v.object({
|
||||
attributes: v.object({
|
||||
social_connections: v.optional(
|
||||
v.nullable(
|
||||
v.object({
|
||||
discord: v.optional(
|
||||
v.nullable(
|
||||
v.object({
|
||||
user_id: v.string(),
|
||||
}),
|
||||
),
|
||||
),
|
||||
}),
|
||||
),
|
||||
),
|
||||
}),
|
||||
id: v.string(),
|
||||
type: v.string(),
|
||||
}),
|
||||
id: z.string(),
|
||||
type: z.string(),
|
||||
}),
|
||||
)
|
||||
.nullish(),
|
||||
links: z.object({ next: z.string() }).nullish(),
|
||||
meta: z.object({
|
||||
pagination: z.object({
|
||||
cursors: z.object({ next: z.string().nullish() }),
|
||||
total: z.number(),
|
||||
),
|
||||
),
|
||||
),
|
||||
links: v.nullish(v.object({ next: v.string() })),
|
||||
meta: v.object({
|
||||
pagination: v.object({
|
||||
cursors: v.object({ next: v.nullish(v.string()) }),
|
||||
total: v.number(),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import { ServerConfig } from "~/config.server";
|
||||
import { STAFF_DISCORD_IDS } from "~/features/admin/admin-constants";
|
||||
import * as TrophyRepository from "~/features/trophies/TrophyRepository.server";
|
||||
@@ -77,10 +77,10 @@ async function fetchPatronData(urlToFetch: string) {
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = patreonRateLimitSchema.safeParse(await response.json());
|
||||
const parsed = v.safeParse(patreonRateLimitSchema, await response.json());
|
||||
const retryAfterSeconds = Math.min(
|
||||
parsed.success
|
||||
? (parsed.data.errors[0]?.retry_after_seconds ??
|
||||
? (parsed.output.errors[0]?.retry_after_seconds ??
|
||||
DEFAULT_RETRY_AFTER_SECONDS)
|
||||
: DEFAULT_RETRY_AFTER_SECONDS,
|
||||
MAX_RETRY_AFTER_SECONDS,
|
||||
@@ -99,7 +99,7 @@ async function fetchPatronData(urlToFetch: string) {
|
||||
);
|
||||
}
|
||||
|
||||
return patronResponseSchema.parse(await response.json());
|
||||
return v.parse(patronResponseSchema, await response.json());
|
||||
}
|
||||
|
||||
throw new Error("Unexpected end of fetch retry loop");
|
||||
@@ -112,7 +112,7 @@ function sleep(ms: number) {
|
||||
function parsePatronData({
|
||||
data,
|
||||
included,
|
||||
}: z.infer<typeof patronResponseSchema>) {
|
||||
}: v.InferOutput<typeof patronResponseSchema>) {
|
||||
const patronsWithIds: Array<
|
||||
{
|
||||
patreonId: string;
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import * as v from "valibot";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { render } from "vitest-browser-react";
|
||||
import { z } from "zod";
|
||||
import { usePersistedMapState, usePersistedState } from "./hooks";
|
||||
import * as PersistedState from "./persisted-state";
|
||||
|
||||
const recentIds = PersistedState.define({
|
||||
key: "test-recent-ids",
|
||||
storage: "local",
|
||||
schema: z.array(z.number()),
|
||||
schema: v.array(v.number()),
|
||||
default: [],
|
||||
});
|
||||
|
||||
const counts = PersistedState.defineMap({
|
||||
keyPrefix: "test-counts__",
|
||||
storage: "local",
|
||||
schema: z.number(),
|
||||
schema: v.number(),
|
||||
default: 0,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as v from "valibot";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { z } from "zod";
|
||||
import * as PersistedState from "./persisted-state";
|
||||
import {
|
||||
assertDecodesToDefault,
|
||||
@@ -9,28 +9,28 @@ import {
|
||||
const numberList = PersistedState.define({
|
||||
key: "test-number-list",
|
||||
storage: "local",
|
||||
schema: z.array(z.number()),
|
||||
schema: v.array(v.number()),
|
||||
default: [],
|
||||
});
|
||||
|
||||
const searchType = PersistedState.define({
|
||||
key: "test-search-type",
|
||||
storage: "local",
|
||||
schema: z.enum(["weapons", "users"]),
|
||||
schema: v.picklist(["weapons", "users"]),
|
||||
default: "weapons",
|
||||
});
|
||||
|
||||
const dismissed = PersistedState.define({
|
||||
key: "test-dismissed",
|
||||
storage: "local",
|
||||
schema: z.boolean(),
|
||||
schema: v.boolean(),
|
||||
default: false,
|
||||
});
|
||||
|
||||
const counts = PersistedState.defineMap({
|
||||
keyPrefix: "test-counts__",
|
||||
storage: "local",
|
||||
schema: z.number(),
|
||||
schema: v.number(),
|
||||
default: 0,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import type { AnySyncSchema } from "~/utils/schema";
|
||||
|
||||
const readCaches = new WeakMap<
|
||||
object,
|
||||
@@ -34,12 +35,12 @@ export interface PersistedMapDefinition<T> extends DefinitionBase<T> {
|
||||
* Decoding is total: the default resolves for missing or malformed values,
|
||||
* legacy plain-string values are accepted where the schema allows them.
|
||||
*/
|
||||
export function define<S extends z.ZodType>(options: {
|
||||
export function define<S extends AnySyncSchema>(options: {
|
||||
key: string;
|
||||
storage: StorageKind;
|
||||
schema: S;
|
||||
default: z.output<S>;
|
||||
}): PersistedDefinition<z.output<S>> {
|
||||
default: v.InferOutput<S>;
|
||||
}): PersistedDefinition<v.InferOutput<S>> {
|
||||
return {
|
||||
key: options.key,
|
||||
storage: options.storage,
|
||||
@@ -52,12 +53,12 @@ export function define<S extends z.ZodType>(options: {
|
||||
* Declares a keyed family of persisted values sharing a storage key prefix,
|
||||
* for maps whose entries are written independently (e.g. per chat room).
|
||||
*/
|
||||
export function defineMap<S extends z.ZodType>(options: {
|
||||
export function defineMap<S extends AnySyncSchema>(options: {
|
||||
keyPrefix: string;
|
||||
storage: StorageKind;
|
||||
schema: S;
|
||||
default: z.output<S>;
|
||||
}): PersistedMapDefinition<z.output<S>> {
|
||||
default: v.InferOutput<S>;
|
||||
}): PersistedMapDefinition<v.InferOutput<S>> {
|
||||
return {
|
||||
keyPrefix: options.keyPrefix,
|
||||
storage: options.storage,
|
||||
@@ -181,14 +182,17 @@ export function prependToRecentList<T>(
|
||||
);
|
||||
}
|
||||
|
||||
function codec<S extends z.ZodType>(schema: S, defaultValue: z.output<S>) {
|
||||
function codec<S extends AnySyncSchema>(
|
||||
schema: S,
|
||||
defaultValue: v.InferOutput<S>,
|
||||
) {
|
||||
return {
|
||||
decode: (raw: string | null): z.output<S> => {
|
||||
decode: (raw: string | null): v.InferOutput<S> => {
|
||||
if (raw === null) return defaultValue;
|
||||
const parsed = schema.safeParse(rawToJson(raw));
|
||||
return parsed.success ? parsed.data : defaultValue;
|
||||
const parsed = v.safeParse(schema, rawToJson(raw));
|
||||
return parsed.success ? parsed.output : defaultValue;
|
||||
},
|
||||
encode: (value: z.output<S>) => JSON.stringify(value),
|
||||
encode: (value: v.InferOutput<S>) => JSON.stringify(value),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
import * as React from "react";
|
||||
import { createBrowserRouter, RouterProvider } from "react-router";
|
||||
import * as v from "valibot";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { render } from "vitest-browser-react";
|
||||
import { z } from "zod";
|
||||
import { useSearchParam, useSearchParamsTyped } from "./hooks";
|
||||
import * as SearchParams from "./search-params";
|
||||
import { SP } from "./search-params";
|
||||
|
||||
const definition = SearchParams.define({
|
||||
page: SP.param(z.number().int().min(1), { default: 1, loader: true }),
|
||||
filters: SP.json(z.object({ q: z.string() }), {
|
||||
page: SP.param(v.pipe(v.number(), v.integer(), v.minValue(1)), {
|
||||
default: 1,
|
||||
loader: true,
|
||||
}),
|
||||
filters: SP.json(v.object({ q: v.string() }), {
|
||||
default: { q: "" },
|
||||
loader: true,
|
||||
resets: ["page"],
|
||||
}),
|
||||
view: SP.param(z.enum(["list", "grid"]), { default: "list", loader: false }),
|
||||
other: SP.param(z.number(), { default: 0, loader: false }),
|
||||
view: SP.param(v.picklist(["list", "grid"]), {
|
||||
default: "list",
|
||||
loader: false,
|
||||
}),
|
||||
other: SP.param(v.number(), { default: 0, loader: false }),
|
||||
});
|
||||
|
||||
let loaderCalls = 0;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ShouldRevalidateFunction } from "react-router";
|
||||
import * as v from "valibot";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { z } from "zod";
|
||||
import * as SearchParams from "./search-params";
|
||||
import { SP } from "./search-params";
|
||||
import {
|
||||
@@ -9,26 +9,34 @@ import {
|
||||
} from "./search-params-test-utils";
|
||||
|
||||
const testDefinition = SearchParams.define({
|
||||
limit: SP.param(z.number().int().min(1).max(100), {
|
||||
default: 24,
|
||||
limit: SP.param(
|
||||
v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(100)),
|
||||
{
|
||||
default: 24,
|
||||
loader: true,
|
||||
},
|
||||
),
|
||||
name: SP.param(v.pipe(v.string(), v.maxLength(20)), {
|
||||
default: "",
|
||||
loader: true,
|
||||
}),
|
||||
name: SP.param(z.string().max(20), { default: "", loader: true }),
|
||||
enabled: SP.param(z.boolean(), { default: false, loader: false }),
|
||||
mode: SP.param(z.enum(["TW", "SZ", "TC"]), {
|
||||
enabled: SP.param(v.boolean(), { default: false, loader: false }),
|
||||
mode: SP.param(v.picklist(["TW", "SZ", "TC"]), {
|
||||
default: "TW",
|
||||
loader: true,
|
||||
}),
|
||||
season: SP.param(z.number().int().nullable(), { loader: true }),
|
||||
ids: SP.param(z.array(z.number().int().positive()), {
|
||||
season: SP.param(v.nullable(v.pipe(v.number(), v.integer())), {
|
||||
loader: true,
|
||||
}),
|
||||
ids: SP.param(v.array(v.pipe(v.number(), v.integer(), v.gtValue(0))), {
|
||||
default: [],
|
||||
loader: false,
|
||||
}),
|
||||
filters: SP.json(
|
||||
z.object({ minValue: z.number(), tags: z.array(z.string()) }),
|
||||
v.object({ minValue: v.number(), tags: v.array(v.string()) }),
|
||||
{ default: { minValue: 0, tags: [] }, loader: true, resets: ["limit"] },
|
||||
),
|
||||
blob: SP.json(z.object({ text: z.string() }), {
|
||||
blob: SP.json(v.object({ text: v.string() }), {
|
||||
default: { text: "" },
|
||||
loader: false,
|
||||
compress: true,
|
||||
@@ -112,7 +120,7 @@ describe("SearchParams.define", () => {
|
||||
|
||||
test("decodes legacy JSON-encoded arrays", () => {
|
||||
const definitionWithModes = SearchParams.define({
|
||||
modes: SP.param(z.array(z.enum(["SZ", "TC", "RM", "CB"])), {
|
||||
modes: SP.param(v.array(v.picklist(["SZ", "TC", "RM", "CB"])), {
|
||||
default: ["SZ", "TC", "RM", "CB"],
|
||||
loader: false,
|
||||
}),
|
||||
@@ -143,19 +151,25 @@ describe("SearchParams.define", () => {
|
||||
|
||||
test("rejects schemas outside the derivation table at define time", () => {
|
||||
expect(() =>
|
||||
SP.param(z.object({ a: z.string() }) as any, {
|
||||
SP.param(v.object({ a: v.string() }) as any, {
|
||||
default: { a: "" },
|
||||
loader: true,
|
||||
}),
|
||||
).toThrow(/derive/);
|
||||
expect(() =>
|
||||
SP.param(z.string().transform((s) => s.length) as any, {
|
||||
default: 0,
|
||||
loader: true,
|
||||
}),
|
||||
SP.param(
|
||||
v.pipe(
|
||||
v.string(),
|
||||
v.transform((s) => s.length),
|
||||
) as any,
|
||||
{
|
||||
default: 0,
|
||||
loader: true,
|
||||
},
|
||||
),
|
||||
).toThrow(/derive/);
|
||||
expect(() =>
|
||||
SP.param(z.array(z.array(z.number())) as any, {
|
||||
SP.param(v.array(v.array(v.number())) as any, {
|
||||
default: [],
|
||||
loader: true,
|
||||
}),
|
||||
@@ -163,8 +177,10 @@ describe("SearchParams.define", () => {
|
||||
});
|
||||
|
||||
test("defaults .nullable() params to null without declaring it", () => {
|
||||
const omitted = SP.param(z.number().int().nullable(), { loader: true });
|
||||
const declared = SP.param(z.number().int().nullable(), {
|
||||
const omitted = SP.param(v.nullable(v.pipe(v.number(), v.integer())), {
|
||||
loader: true,
|
||||
});
|
||||
const declared = SP.param(v.nullable(v.pipe(v.number(), v.integer())), {
|
||||
default: null,
|
||||
loader: true,
|
||||
});
|
||||
@@ -178,31 +194,26 @@ describe("SearchParams.define", () => {
|
||||
|
||||
test("rejects .optional() and non-null defaults for .nullable()", () => {
|
||||
expect(() =>
|
||||
SP.param(z.number().optional() as any, { default: 1, loader: true }),
|
||||
SP.param(v.optional(v.number()) as any, { default: 1, loader: true }),
|
||||
).toThrow(/nullable/);
|
||||
expect(() =>
|
||||
SP.param(z.number().nullable(), { default: 1 as any, loader: true }),
|
||||
SP.param(v.nullable(v.number()), { default: 1 as any, loader: true }),
|
||||
).toThrow(/null as its default/);
|
||||
});
|
||||
|
||||
test("supports SP.custom codecs with total decode via issues", () => {
|
||||
const isoDate = z.codec(z.string(), z.date(), {
|
||||
decode: (value, payload) => {
|
||||
test("supports SP.custom codecs with total decode", () => {
|
||||
const isoDate = SearchParams.codec(v.date(), {
|
||||
decode: (value) => {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
payload.issues.push({
|
||||
code: "custom",
|
||||
message: "invalid date",
|
||||
input: value,
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
return date;
|
||||
return Number.isNaN(date.getTime()) ? undefined : date;
|
||||
},
|
||||
encode: (date) => date.toISOString(),
|
||||
});
|
||||
const customDefinition = SearchParams.define({
|
||||
from: SP.custom(isoDate.nullable(), { default: null, loader: true }),
|
||||
from: SP.custom(SearchParams.nullableCodec(isoDate), {
|
||||
default: null,
|
||||
loader: true,
|
||||
}),
|
||||
});
|
||||
|
||||
const value = new Date("2024-05-01T12:00:00.000Z");
|
||||
@@ -220,7 +231,7 @@ describe("SearchParams.define", () => {
|
||||
test("rejects resets pointing at unknown params", () => {
|
||||
expect(() =>
|
||||
SearchParams.define({
|
||||
a: SP.param(z.number(), { default: 0, loader: true, resets: ["b"] }),
|
||||
a: SP.param(v.number(), { default: 0, loader: true, resets: ["b"] }),
|
||||
}),
|
||||
).toThrow(/unknown param/);
|
||||
});
|
||||
@@ -310,7 +321,7 @@ describe("SearchParams.href", () => {
|
||||
|
||||
test("encodes arrays as repeated keys and empty arrays as one empty value", () => {
|
||||
const definitionWithDefault = SearchParams.define({
|
||||
modes: SP.param(z.array(z.enum(["SZ", "TC"])), {
|
||||
modes: SP.param(v.array(v.picklist(["SZ", "TC"])), {
|
||||
default: ["SZ", "TC"],
|
||||
loader: false,
|
||||
}),
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { ShouldRevalidateFunction } from "react-router";
|
||||
import { isDeepEqual } from "remeda";
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import { compressToBase64, decompressFromBase64 } from "~/utils/compression";
|
||||
|
||||
type AnySchema = v.GenericSchema<any, any>;
|
||||
|
||||
const COMPRESSED_PREFIX = "lz~";
|
||||
const ESCAPED_PREFIX = "lz~~";
|
||||
const DECODE_CACHE_MAX_SIZE = 300;
|
||||
@@ -253,6 +255,56 @@ export function pickRelevantSearch(keys: string[], search: string): string {
|
||||
return picked.toString();
|
||||
}
|
||||
|
||||
/** Bidirectional URL encoding for an `SP.custom` param. */
|
||||
export interface ParamCodec<Value> {
|
||||
/** Decodes a plain URL value; `undefined` means malformed, resolving the param to its default. */
|
||||
decode: (plain: string) => Value | undefined;
|
||||
/** Encodes a value to its canonical plain URL form. Must succeed for every value of the type. */
|
||||
encode: (value: Value) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link ParamCodec} whose decode result is validated against
|
||||
* `schema`. The `decode` implementation returns `undefined` (or any value the
|
||||
* schema rejects) for malformed input.
|
||||
*/
|
||||
export function codec<TSchema extends AnySchema>(
|
||||
schema: TSchema,
|
||||
impl: {
|
||||
decode: (encoded: string) => unknown;
|
||||
encode: (value: v.InferOutput<TSchema>) => string;
|
||||
},
|
||||
): ParamCodec<v.InferOutput<TSchema>> {
|
||||
return {
|
||||
decode: (encoded) => {
|
||||
const parsed = v.safeParse(schema, impl.decode(encoded));
|
||||
return parsed.success ? parsed.output : undefined;
|
||||
},
|
||||
encode: impl.encode,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Widens a codec to also accept `null` as its value. `null` must be the
|
||||
* param's default, so it never reaches `encode` (encoding the default omits
|
||||
* the param from the URL).
|
||||
*/
|
||||
export function nullableCodec<Value>(
|
||||
inner: ParamCodec<Value>,
|
||||
): ParamCodec<Value | null> {
|
||||
return {
|
||||
decode: inner.decode,
|
||||
encode: (value) => {
|
||||
if (value === null) {
|
||||
throw new Error(
|
||||
"Cannot encode null; a nullable search param's default is null, which is omitted from the URL",
|
||||
);
|
||||
}
|
||||
return inner.encode(value);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Param declaration helpers. `SP.param` is the canonical declaration deriving
|
||||
* the URL encoding from the value schema; `SP.json` and `SP.custom` are the
|
||||
@@ -260,42 +312,43 @@ export function pickRelevantSearch(keys: string[], search: string): string {
|
||||
*/
|
||||
export const SP = {
|
||||
/**
|
||||
* Declares a param whose URL encoding is derived from the zod value
|
||||
* Declares a param whose URL encoding is derived from the valibot value
|
||||
* schema's type tree. Supported shapes: strings, numbers, booleans, string
|
||||
* and number enums/literals, same-base-type unions, arrays of those
|
||||
* (encoded as repeated keys) and a top-level `.nullable()` wrapper (`null`
|
||||
* encodes as param absent, so `default` is omitted for those). Anything else
|
||||
* is a `define()`-time error — use `SP.json` or `SP.custom` instead.
|
||||
*/
|
||||
param<S extends z.ZodType>(
|
||||
param<S extends AnySchema>(
|
||||
schema: S,
|
||||
opts: ParamOptions<z.output<S>>,
|
||||
): ParamDef<z.output<S>> {
|
||||
opts: ParamOptions<v.InferOutput<S>>,
|
||||
): ParamDef<v.InferOutput<S>> {
|
||||
const resolved = resolveOptions(opts);
|
||||
let core: z.ZodType = schema;
|
||||
let core: AnySchema = schema;
|
||||
|
||||
if (core instanceof z.ZodOptional) {
|
||||
if (core.type === "optional" || core.type === "nullish") {
|
||||
throw new Error(
|
||||
"Search params use .nullable() instead of .optional() (null encodes as param absent)",
|
||||
"Search params use v.nullable() instead of v.optional() (null encodes as param absent)",
|
||||
);
|
||||
}
|
||||
if (core instanceof z.ZodNullable) {
|
||||
if (core.type === "nullable") {
|
||||
if (resolved.default !== null) {
|
||||
throw new Error(
|
||||
"A .nullable() search param must have null as its default, otherwise null and the default could not be told apart in the URL",
|
||||
"A v.nullable() search param must have null as its default, otherwise null and the default could not be told apart in the URL",
|
||||
);
|
||||
}
|
||||
core = core.unwrap() as z.ZodType;
|
||||
core = (core as unknown as { wrapped: AnySchema }).wrapped;
|
||||
}
|
||||
|
||||
if (core instanceof z.ZodArray) {
|
||||
const itemBase = deriveScalarBase(core.element as z.ZodType);
|
||||
if (core.type === "array") {
|
||||
const itemSchema = (core as unknown as { item: AnySchema }).item;
|
||||
const itemBase = deriveScalarBase(itemSchema);
|
||||
if (!itemBase) {
|
||||
throw new Error(
|
||||
`Cannot derive an URL encoding for the array item schema of a search param (got ${describeSchema(core.element as z.ZodType)}). Use SP.json or SP.custom.`,
|
||||
`Cannot derive an URL encoding for the array item schema of a search param (got ${describeSchema(itemSchema)}). Use SP.json or SP.custom.`,
|
||||
);
|
||||
}
|
||||
return arrayParam(schema, core, itemBase, resolved);
|
||||
return arrayParam(schema, itemSchema, itemBase, resolved);
|
||||
}
|
||||
|
||||
const base = deriveScalarBase(core);
|
||||
@@ -310,20 +363,21 @@ export const SP = {
|
||||
/** Declares the 1-based `page` param of a paginated route, as `useSearchParamPagination` expects it. */
|
||||
page(opts?: { max?: number; resets?: string[] }): ParamDef<number> {
|
||||
return SP.param(
|
||||
z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(opts?.max ?? DEFAULT_MAX_PAGE),
|
||||
v.pipe(
|
||||
v.number(),
|
||||
v.integer(),
|
||||
v.minValue(1),
|
||||
v.maxValue(opts?.max ?? DEFAULT_MAX_PAGE),
|
||||
),
|
||||
{ default: 1, loader: true, resets: opts?.resets },
|
||||
);
|
||||
},
|
||||
|
||||
/** Declares a param encoded as `JSON.stringify` in a single value. For objects and whole-array-as-one-param values. */
|
||||
json<S extends z.ZodType>(
|
||||
json<S extends AnySchema>(
|
||||
schema: S,
|
||||
opts: ParamOptions<z.output<S>>,
|
||||
): ParamDef<z.output<S>> {
|
||||
opts: ParamOptions<v.InferOutput<S>>,
|
||||
): ParamDef<v.InferOutput<S>> {
|
||||
const resolved = resolveOptions(opts);
|
||||
|
||||
return {
|
||||
@@ -338,20 +392,20 @@ export const SP = {
|
||||
} catch {
|
||||
return resolved.default;
|
||||
}
|
||||
const parsed = schema.safeParse(json);
|
||||
return parsed.success ? parsed.data : resolved.default;
|
||||
const parsed = v.safeParse(schema, json);
|
||||
return parsed.success ? parsed.output : resolved.default;
|
||||
},
|
||||
encodePlain: (value) => [JSON.stringify(value)],
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Escape hatch: declares a param from a `z.codec(z.string(), valueSchema, ...)`
|
||||
* Escape hatch: declares a param from a {@link ParamCodec} (see `codec`)
|
||||
* passed directly. The codec's `decode` may accept legacy formats while
|
||||
* `encode` always emits the canonical one.
|
||||
*/
|
||||
custom<Value>(
|
||||
codec: z.ZodType<Value, string | null>,
|
||||
paramCodec: ParamCodec<Value>,
|
||||
opts: ParamOptions<Value>,
|
||||
): ParamDef<Value> {
|
||||
const resolved = resolveOptions(opts);
|
||||
@@ -362,18 +416,10 @@ export const SP = {
|
||||
if (values.length === 0) return resolved.default;
|
||||
const plain = unwrapValue(values[0]);
|
||||
if (plain === DECODE_FAILED) return resolved.default;
|
||||
const parsed = z.safeDecode(codec, plain);
|
||||
return parsed.success ? parsed.data : resolved.default;
|
||||
},
|
||||
encodePlain: (value) => {
|
||||
const encoded = z.safeEncode(codec, value);
|
||||
if (!encoded.success || typeof encoded.data !== "string") {
|
||||
throw new Error(
|
||||
"Encoding a search param value failed; SP.custom codecs must encode every value of their type",
|
||||
);
|
||||
}
|
||||
return [encoded.data];
|
||||
const decoded = paramCodec.decode(plain);
|
||||
return decoded === undefined ? resolved.default : decoded;
|
||||
},
|
||||
encodePlain: (value) => [paramCodec.encode(value)],
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -403,7 +449,7 @@ function baseDef<T>(
|
||||
}
|
||||
|
||||
function scalarParam<T>(
|
||||
schema: z.ZodType,
|
||||
schema: AnySchema,
|
||||
base: ScalarBase,
|
||||
opts: ResolvedParamOptions<T>,
|
||||
): ParamDef<T> {
|
||||
@@ -415,21 +461,19 @@ function scalarParam<T>(
|
||||
if (plain === DECODE_FAILED) return opts.default;
|
||||
const candidate = plainToScalar(plain, base);
|
||||
if (candidate === DECODE_FAILED) return opts.default;
|
||||
const parsed = schema.safeParse(candidate);
|
||||
return parsed.success ? (parsed.data as T) : opts.default;
|
||||
const parsed = v.safeParse(schema, candidate);
|
||||
return parsed.success ? (parsed.output as T) : opts.default;
|
||||
},
|
||||
encodePlain: (value) => [String(value)],
|
||||
};
|
||||
}
|
||||
|
||||
function arrayParam<T>(
|
||||
schema: z.ZodType,
|
||||
arraySchema: z.ZodArray,
|
||||
schema: AnySchema,
|
||||
itemSchema: AnySchema,
|
||||
itemBase: ScalarBase,
|
||||
opts: ResolvedParamOptions<T>,
|
||||
): ParamDef<T> {
|
||||
const itemSchema = arraySchema.element as z.ZodType;
|
||||
|
||||
return {
|
||||
...baseDef(opts),
|
||||
decodeValues: (values) => {
|
||||
@@ -463,12 +507,12 @@ function arrayParam<T>(
|
||||
for (const item of items) {
|
||||
const candidate = plainToScalar(item, itemBase);
|
||||
if (candidate === DECODE_FAILED) continue;
|
||||
const parsed = itemSchema.safeParse(candidate);
|
||||
if (parsed.success) members.push(parsed.data);
|
||||
const parsed = v.safeParse(itemSchema, candidate);
|
||||
if (parsed.success) members.push(parsed.output);
|
||||
}
|
||||
|
||||
const parsed = schema.safeParse(members);
|
||||
return parsed.success ? (parsed.data as T) : opts.default;
|
||||
const parsed = v.safeParse(schema, members);
|
||||
return parsed.success ? (parsed.output as T) : opts.default;
|
||||
},
|
||||
encodePlain: (value) => {
|
||||
const items = value as unknown[];
|
||||
@@ -495,19 +539,22 @@ function plainToScalar(
|
||||
return DECODE_FAILED;
|
||||
}
|
||||
|
||||
function deriveScalarBase(schema: z.ZodType): ScalarBase | null {
|
||||
if (schema instanceof z.ZodString) return "string";
|
||||
if (schema instanceof z.ZodNumber) return "number";
|
||||
if (schema instanceof z.ZodBoolean) return "boolean";
|
||||
function deriveScalarBase(schema: AnySchema): ScalarBase | null {
|
||||
if (hasNonValidationPipeItems(schema)) return null;
|
||||
|
||||
if (schema instanceof z.ZodEnum) {
|
||||
return uniformTypeOf(schema.options);
|
||||
if (schema.type === "string") return "string";
|
||||
if (schema.type === "number") return "number";
|
||||
if (schema.type === "boolean") return "boolean";
|
||||
|
||||
if (schema.type === "picklist" || schema.type === "enum") {
|
||||
return uniformTypeOf((schema as unknown as { options: unknown[] }).options);
|
||||
}
|
||||
if (schema instanceof z.ZodLiteral) {
|
||||
return uniformTypeOf(Array.from(schema.values));
|
||||
if (schema.type === "literal") {
|
||||
return uniformTypeOf([(schema as unknown as { literal: unknown }).literal]);
|
||||
}
|
||||
if (schema instanceof z.ZodUnion) {
|
||||
const bases = (schema.options as z.ZodType[]).map(deriveScalarBase);
|
||||
if (schema.type === "union") {
|
||||
const options = (schema as unknown as { options: AnySchema[] }).options;
|
||||
const bases = options.map(deriveScalarBase);
|
||||
if (bases[0] && bases.every((base) => base === bases[0])) {
|
||||
return bases[0];
|
||||
}
|
||||
@@ -517,6 +564,28 @@ function deriveScalarBase(schema: z.ZodType): ScalarBase | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A pipe may only add validations, metadata and type-preserving
|
||||
* transformations (e.g. `v.trim()`) on top of its base schema. A custom
|
||||
* transform or a nested schema changes the value's type, so the URL encoding
|
||||
* cannot be derived from the base type.
|
||||
*/
|
||||
function hasNonValidationPipeItems(schema: AnySchema): boolean {
|
||||
if (!("pipe" in schema)) return false;
|
||||
|
||||
const pipeItems = (
|
||||
schema as unknown as { pipe: Array<{ kind: string; type: string }> }
|
||||
).pipe;
|
||||
return pipeItems
|
||||
.slice(1)
|
||||
.some(
|
||||
(item) =>
|
||||
item.kind === "schema" ||
|
||||
item.type === "transform" ||
|
||||
item.type === "raw_transform",
|
||||
);
|
||||
}
|
||||
|
||||
function uniformTypeOf(values: unknown[]): ScalarBase | null {
|
||||
const types = new Set(values.map((value) => typeof value));
|
||||
if (types.size !== 1) return null;
|
||||
@@ -528,8 +597,8 @@ function uniformTypeOf(values: unknown[]): ScalarBase | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function describeSchema(schema: z.ZodType) {
|
||||
return schema.constructor.name;
|
||||
function describeSchema(schema: AnySchema) {
|
||||
return schema.type;
|
||||
}
|
||||
|
||||
function toSearchParams(
|
||||
|
||||
@@ -1,62 +1,62 @@
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import type { Unpacked } from "~/utils/types";
|
||||
|
||||
export const streamsSchema = z.object({
|
||||
data: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
user_id: z.string(),
|
||||
user_login: z.string(),
|
||||
user_name: z.string(),
|
||||
game_id: z.string(),
|
||||
game_name: z.string(),
|
||||
type: z.string(),
|
||||
title: z.string(),
|
||||
viewer_count: z.number(),
|
||||
started_at: z.string(),
|
||||
language: z.string(),
|
||||
thumbnail_url: z.string(),
|
||||
tag_ids: z.array(z.unknown()),
|
||||
tags: z.array(z.string()).nullish(),
|
||||
is_mature: z.boolean(),
|
||||
export const streamsSchema = v.object({
|
||||
data: v.array(
|
||||
v.object({
|
||||
id: v.string(),
|
||||
user_id: v.string(),
|
||||
user_login: v.string(),
|
||||
user_name: v.string(),
|
||||
game_id: v.string(),
|
||||
game_name: v.string(),
|
||||
type: v.string(),
|
||||
title: v.string(),
|
||||
viewer_count: v.number(),
|
||||
started_at: v.string(),
|
||||
language: v.string(),
|
||||
thumbnail_url: v.string(),
|
||||
tag_ids: v.array(v.unknown()),
|
||||
tags: v.nullish(v.array(v.string())),
|
||||
is_mature: v.boolean(),
|
||||
}),
|
||||
),
|
||||
pagination: z.object({ cursor: z.string().nullish() }),
|
||||
pagination: v.object({ cursor: v.nullish(v.string()) }),
|
||||
});
|
||||
|
||||
export const tokenResponseSchema = z.object({
|
||||
access_token: z.string(),
|
||||
expires_in: z.number(),
|
||||
token_type: z.string(),
|
||||
export const tokenResponseSchema = v.object({
|
||||
access_token: v.string(),
|
||||
expires_in: v.number(),
|
||||
token_type: v.string(),
|
||||
});
|
||||
|
||||
export const usersSchema = z.object({
|
||||
data: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
login: z.string(),
|
||||
display_name: z.string(),
|
||||
export const usersSchema = v.object({
|
||||
data: v.array(
|
||||
v.object({
|
||||
id: v.string(),
|
||||
login: v.string(),
|
||||
display_name: v.string(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const videosSchema = z.object({
|
||||
data: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
user_id: z.string(),
|
||||
user_login: z.string(),
|
||||
title: z.string(),
|
||||
created_at: z.string(),
|
||||
duration: z.string(),
|
||||
view_count: z.number(),
|
||||
type: z.string(),
|
||||
export const videosSchema = v.object({
|
||||
data: v.array(
|
||||
v.object({
|
||||
id: v.string(),
|
||||
user_id: v.string(),
|
||||
user_login: v.string(),
|
||||
title: v.string(),
|
||||
created_at: v.string(),
|
||||
duration: v.string(),
|
||||
view_count: v.number(),
|
||||
type: v.string(),
|
||||
}),
|
||||
),
|
||||
pagination: z.object({ cursor: z.string().nullish() }),
|
||||
pagination: v.object({ cursor: v.nullish(v.string()) }),
|
||||
});
|
||||
|
||||
export type StreamsResponse = z.infer<typeof streamsSchema>;
|
||||
export type RawStream = Unpacked<z.infer<typeof streamsSchema>["data"]>;
|
||||
export type UsersResponse = z.infer<typeof usersSchema>;
|
||||
export type RawVideo = Unpacked<z.infer<typeof videosSchema>["data"]>;
|
||||
export type StreamsResponse = v.InferOutput<typeof streamsSchema>;
|
||||
export type RawStream = Unpacked<v.InferOutput<typeof streamsSchema>["data"]>;
|
||||
export type UsersResponse = v.InferOutput<typeof usersSchema>;
|
||||
export type RawVideo = Unpacked<v.InferOutput<typeof videosSchema>["data"]>;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cachified } from "@epic-web/cachified";
|
||||
import * as v from "valibot";
|
||||
import { cache } from "~/utils/cache.server";
|
||||
import { IS_E2E_TEST_RUN } from "~/utils/e2e";
|
||||
import { logger } from "~/utils/logger";
|
||||
@@ -138,10 +139,10 @@ async function getStreamsChunk(cursor?: string): Promise<StreamsResponse> {
|
||||
}`,
|
||||
);
|
||||
|
||||
const parsed = streamsSchema.safeParse(await res.json());
|
||||
const parsed = v.safeParse(streamsSchema, await res.json());
|
||||
if (!parsed.success) {
|
||||
throw new Error(parsed.error.message);
|
||||
throw new Error(v.summarize(parsed.issues));
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
return parsed.output;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cachified } from "@epic-web/cachified";
|
||||
import * as v from "valibot";
|
||||
import { cache } from "~/utils/cache.server";
|
||||
import { tokenResponseSchema } from "./schemas";
|
||||
import { getTwitchEnvVars } from "./utils.server";
|
||||
@@ -16,12 +17,12 @@ async function getFreshToken() {
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = tokenResponseSchema.safeParse(await res.json());
|
||||
const parsed = v.safeParse(tokenResponseSchema, await res.json());
|
||||
if (!parsed.success) {
|
||||
throw new Error("Token response schema validation failed");
|
||||
}
|
||||
|
||||
return parsed.data.access_token;
|
||||
return parsed.output.access_token;
|
||||
}
|
||||
|
||||
export function getToken() {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as R from "remeda";
|
||||
import * as v from "valibot";
|
||||
import { twitchFetch } from "./fetch";
|
||||
import {
|
||||
type RawVideo,
|
||||
@@ -21,14 +22,14 @@ export async function getUsersByLogin(
|
||||
`https://api.twitch.tv/helix/users?${params}`,
|
||||
);
|
||||
|
||||
const parsed = usersSchema.safeParse(await res.json());
|
||||
const parsed = v.safeParse(usersSchema, await res.json());
|
||||
if (!parsed.success) {
|
||||
throw new Error(
|
||||
`Twitch users schema validation failed: ${parsed.error.message}`,
|
||||
`Twitch users schema validation failed: ${v.summarize(parsed.issues)}`,
|
||||
);
|
||||
}
|
||||
|
||||
results.push(...parsed.data.data);
|
||||
results.push(...parsed.output.data);
|
||||
}
|
||||
|
||||
return results;
|
||||
@@ -49,17 +50,17 @@ export async function getArchiveVideos(userId: string): Promise<RawVideo[]> {
|
||||
|
||||
const res = await twitchFetch(url.toString());
|
||||
|
||||
const parsed = videosSchema.safeParse(await res.json());
|
||||
const parsed = v.safeParse(videosSchema, await res.json());
|
||||
if (!parsed.success) {
|
||||
throw new Error(
|
||||
`Twitch videos schema validation failed: ${parsed.error.message}`,
|
||||
`Twitch videos schema validation failed: ${v.summarize(parsed.issues)}`,
|
||||
);
|
||||
}
|
||||
|
||||
results.push(...parsed.data.data);
|
||||
results.push(...parsed.output.data);
|
||||
|
||||
if (!parsed.data.pagination.cursor) break;
|
||||
cursor = parsed.data.pagination.cursor;
|
||||
if (!parsed.output.pagination.cursor) break;
|
||||
cursor = parsed.output.pagination.cursor;
|
||||
}
|
||||
|
||||
return results;
|
||||
|
||||
Reference in New Issue
Block a user