Giter Site home page Giter Site logo

rileytomasek / zodix Goto Github PK

View Code? Open in Web Editor NEW
332.0 4.0 13.0 146 KB

Zod utilities for Remix loaders and actions.

License: MIT License

JavaScript 1.42% TypeScript 98.58%
form-validation formdata remix remix-run schema-validation typescript urlsearchparams validation zod

zodix's Introduction

Zodix

Build Status npm version

Zodix is a collection of Zod utilities for Remix loaders and actions. It abstracts the complexity of parsing and validating FormData and URLSearchParams so your loaders/actions stay clean and are strongly typed.

Remix loaders often look like:

export async function loader({ params, request }: LoaderArgs) {
  const { id } = params;
  const url = new URL(request.url);
  const count = url.searchParams.get('count') || '10';
  if (typeof id !== 'string') {
    throw new Error('id must be a string');
  }
  const countNumber = parseInt(count, 10);
  if (isNaN(countNumber)) {
    throw new Error('count must be a number');
  }
  // Fetch data with id and countNumber
};

Here is the same loader with Zodix:

export async function loader({ params, request }: LoaderArgs) {
  const { id } = zx.parseParams(params, { id: z.string() });
  const { count } = zx.parseQuery(request, { count: zx.NumAsString });
  // Fetch data with id and countNumber
};

Check the example app for complete examples of common patterns.

Highlights

  • Significantly reduce Remix action/loader bloat
  • Avoid the oddities of FormData and URLSearchParams
  • Tiny with no external dependencies (Less than 1kb gzipped)
  • Use existing Zod schemas, or write them on the fly
  • Custom Zod schemas for stringified numbers, booleans, and checkboxes
  • Throw errors meant for Remix CatchBoundary by default
  • Supports non-throwing parsing for custom validation/errors
  • Works with all Remix runtimes (Node, Deno, Vercel, Cloudflare, etc)
  • Full unit test coverage

Setup

Install with npm, yarn, pnpm, etc.

npm install zodix zod

Import the zx object, or specific functions:

import { zx } from 'zodix';
// import { parseParams, NumAsString } from 'zodix';

Usage

zx.parseParams(params: Params, schema: Schema)

Parse and validate the Params object from LoaderArgs['params'] or ActionArgs['params'] using a Zod shape:

export async function loader({ params }: LoaderArgs) {
  const { userId, noteId } = zx.parseParams(params, {
    userId: z.string(),
    noteId: z.string(),
  });
};

The same as above, but using an existing Zod object schema:

// This is if you have many pages that share the same params.
export const ParamsSchema = z.object({ userId: z.string(), noteId: z.string() });

export async function loader({ params }: LoaderArgs) {
  const { userId, noteId } = zx.parseParams(params, ParamsSchema);
};

zx.parseForm(request: Request, schema: Schema)

Parse and validate FormData from a Request in a Remix action and avoid the tedious FormData dance:

export async function action({ request }: ActionArgs) {
  const { email, password, saveSession } = await zx.parseForm(request, {
    email: z.string().email(),
    password: z.string().min(6),
    saveSession: zx.CheckboxAsString,
  });
};

Integrate with existing Zod schemas and models/controllers:

// db.ts
export const CreateNoteSchema = z.object({
  userId: z.string(),
  title: z.string(),
  category: NoteCategorySchema.optional(),
});

export function createNote(note: z.infer<typeof CreateNoteSchema>) {}
import { CreateNoteSchema, createNote } from './db';

export async function action({ request }: ActionArgs) {
  const formData = await zx.parseForm(request, CreateNoteSchema);
  createNote(formData); // No TypeScript errors here
};

zx.parseQuery(request: Request, schema: Schema)

Parse and validate the query string (search params) of a Request:

export async function loader({ request }: LoaderArgs) {
  const { count, page } = zx.parseQuery(request, {
    // NumAsString parses a string number ("5") and returns a number (5)
    count: zx.NumAsString,
    page: zx.NumAsString,
  });
};

zx.parseParamsSafe() / zx.parseFormSafe() / zx.parseQuerySafe()

These work the same as the non-safe versions, but don't throw when validation fails. They use z.parseSafe() and always return an object with the parsed data or an error.

export async function action(args: ActionArgs) {
  const results = await zx.parseFormSafe(args.request, {
    email: z.string().email({ message: "Invalid email" }),
    password: z.string().min(8, { message: "Password must be at least 8 characters" }),
  });
  return json({
    success: results.success,
    error: results.error,
  });
}

Check the login page example for a full example.

Error Handling

parseParams(), parseForm(), and parseQuery()

These functions throw a 400 Response when the parsing fails. This works nicely with Remix catch boundaries and should be used for parsing things that should rarely fail and don't require custom error handling. You can pass a custom error message or status code.

export async function loader({ params }: LoaderArgs) {
  const { postId } = zx.parseParams(
    params,
    { postId: zx.NumAsString },
    { message: "Invalid postId parameter", status: 400 }
  );
  const post = await getPost(postId);
  return { post };
}
export function CatchBoundary() {
  const caught = useCatch();
  return <h1>Caught error: {caught.statusText}</h1>;
}

Check the post page example for a full example.

parseParamsSafe(), parseFormSafe(), and parseQuerySafe()

These functions are great for form validation because they don't throw when parsing fails. They always return an object with this shape:

{ success: boolean; error?: ZodError; data?: <parsed data>; }

You can then handle errors in the action and access them in the component using useActionData(). Check the login page example for a full example.

Helper Zod Schemas

Because FormData and URLSearchParams serialize all values to strings, you often end up with things like "5", "on" and "true". The helper schemas handle parsing and validating strings representing other data types and are meant to be used with the parse functions.

Available Helpers

zx.BoolAsString

  • "true"true
  • "false"false
  • "notboolean" → throws ZodError

zx.CheckboxAsString

  • "on"true
  • undefinedfalse
  • "anythingbuton" → throws ZodError

zx.IntAsString

  • "3"3
  • "3.14" → throws ZodError
  • "notanumber" → throws ZodError

zx.NumAsString

  • "3"3
  • "3.14"3.14
  • "notanumber" → throws ZodError

See the tests for more details.

Usage

const Schema = z.object({
  isAdmin: zx.BoolAsString,
  agreedToTerms: zx.CheckboxAsString,
  age: zx.IntAsString,
  cost: zx.NumAsString,
});

const parsed = Schema.parse({
  isAdmin: 'true',
  agreedToTerms: 'on',
  age: '38',
  cost: '10.99'
});

/*
parsed = {
  isAdmin: true,
  agreedToTerms: true,
  age: 38,
  cost: 10.99
}
*/

Extras

Custom URLSearchParams parsing

You may have URLs with query string that look like ?ids[]=1&ids[]=2 or ?ids=1,2 that aren't handled as desired by the built in URLSearchParams parsing.

You can pass a custom function, or use a library like query-string to parse them with Zodix.

// Create a custom parser function
type ParserFunction = (params: URLSearchParams) => Record<string, string | string[]>;
const customParser: ParserFunction = () => { /* ... */ };

// Parse non-standard search params
const search = new URLSearchParams(`?ids[]=id1&ids[]=id2`);
const { ids } = zx.parseQuery(
  request,
  { ids: z.array(z.string()) }
  { parser: customParser }
);

// ids = ['id1', 'id2']

Actions with Multiple Intents

Zod discriminated unions are great for helping with actions that handle multiple intents like this:

// This adds type narrowing by the intent property
const Schema = z.discriminatedUnion('intent', [
  z.object({ intent: z.literal('delete'), id: z.string() }),
  z.object({ intent: z.literal('create'), name: z.string() }),
]);

export async function action({ request }: ActionArgs) {
  const data = await zx.parseForm(request, Schema);
  switch (data.intent) {
    case 'delete':
      // data is now narrowed to { intent: 'delete', id: string }
      return;
    case 'create':
      // data is now narrowed to { intent: 'create', name: string }
      return;
    default:
      // data is now narrowed to never. This will error if a case is missing.
      const _exhaustiveCheck: never = data;
  }
};

zodix's People

Contributors

abelsj60 avatar antoinechalifour avatar cmoney667 avatar mdoury avatar onurgvnc avatar prodbygr avatar rileytomasek avatar sergiodxa avatar setvik avatar tgdn avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar

zodix's Issues

zx.parseQuery errors in component using SSR

Trying to parse search parameters from Remix's useSearchParams in a component but I get

Error: Hydration failed because the initial UI does not match what was rendered on the server.
    at throwOnHydrationMismatch (react-dom.development.js:12507:9)
    at tryToClaimNextHydratableInstance (react-dom.development.js:12535:7)
    at updateHostComponent (react-dom.development.js:19902:5)
    at beginWork (react-dom.development.js:21618:14)
    at beginWork$1 (react-dom.development.js:27426:14)
    at performUnitOfWork (react-dom.development.js:26560:12)
    at workLoopConcurrent (react-dom.development.js:26543:5)
    at renderRootConcurrent (react-dom.development.js:26505:7)
    at performConcurrentWorkOnRoot (react-dom.development.js:25738:38)
    at workLoop (scheduler.development.js:266:34)

Unable to parse file uploads handled with `unstable_createFileUploadHandler`

I'm trying to make file uploads type safe using zx.parseFormSafe but I'm not able to parse the NodeOnDiskFile class.

import type { ActionArgs } from "@remix-run/node";
import {
  json,
  NodeOnDiskFile,
  unstable_composeUploadHandlers,
  unstable_createFileUploadHandler,
  unstable_createMemoryUploadHandler,
  unstable_parseMultipartFormData,
} from "@remix-run/node";
import type { ZodError } from "zod";
import { z } from "zod";
import { zx } from "zodix";

function errorAtPath(error: ZodError, path: string) {
  return error.issues.find((issue) => issue.path[0] === path)?.message;
}

export async function action({ request }: ActionArgs) {
  const uploadHandler = unstable_composeUploadHandlers(
    unstable_createFileUploadHandler({
      maxPartSize: 5_000_000,
      file: ({ filename }) => filename,
    }),
    // parse everything else into memory
    unstable_createMemoryUploadHandler()
  );
  const formData = await unstable_parseMultipartFormData(
    request,
    uploadHandler
  );

  const results = await zx.parseFormSafe(formData, {
    file: z.instanceof(NodeOnDiskFile),
    // And some more fields that work just fine 👌
  });

  if (results.success) {
    return await doSomethingWithImage(results.data);
  } else {
    return json({
      success: results.success,
      error: errorAtPath(results.error, "image"),
    });
  }
}

formData.get("image") instanceof NodeOnDiskFile actually evaluates to true.

I found a rather similar issue on the zod repo where running z.instanceof(File) on the server resulted in a Reference Error because the File class is not defined in Node, which is not the case of NodeOnDiskFile.

I tried another method to parse the data which would result in an any type inference but would at least throw for missing data :

const results = await zx.parseFormSafe(formData, {
    image: z.any().refine((file: unknown) => {
      console.log({ file }); 
      return Boolean(file);
    }, "Image is required."),
  });

Which logged { file: '[object File]' } suggesting the file is casted to a string before it reaches the refinement function.

I don't know if this issue comes from zod or from zodix but maybe someone encountered the issue before and found a workaround.

Thanks!

Support other runtimes

Right now, the package uses @remix-run/node to get the LoaderArgs type (and if you accept #1 to get the FormData class).

This creates a hard requirement on Node as the runtime, but a Remix app could use Cloudflare or Deno as runtimes.

You can solve this by importing from @remix-run/server-runtime. The LoaderArgs type is exported from there and re-exported from the runtime packages, and this way you avoid the dependency on a runtime.

Zodix modifying FormData objects

Thanks for the library! Is there a reason Zodix modifies incoming FormData objects (https://github.com/rileytomasek/zodix/blob/master/src/parsers.ts#L189)? We just found a regression in our app related to this behavior as it pertains to FormData with file uploads.

We have a Remix action function that handles incoming FormData differently depending on the FormData's subaction, and one of these handlers expects a File to be present in FormData. We initially use Zodix to parse the subaction via a call to parseFormSafe. Once we know the subaction we pass the FormData towards further Zod validation (variable schema depending on the subaction). Before parsing through Zodix formData.get('file') yields a File object (expected by our downstream validation schema), and then after it yields a string (ex. '{"_name":"file.json","_lastModified":1674149960469}') which causes the downstream schema to fail.

We're giving Zodix a request clone as a workaround, but wanted to toss this behavior on the radar as we would like to unwind it eventually if possible.

[BUG]: Typescript compilation fails in Remix V2

How to reproduce:

Step 1: In a Remix V2 project with Zodix installed and implemented in a loader or action, run the following from the cli:

npx tsc --watch

Expected behavior

[9:12:25 AM] Found 0 errors. Watching for file changes.

Actual behavior

node_modules/zodix/dist/parsers.d.ts:2:15 - error TS2305: Module '"@remix-run/server-runtime"' has no exported member 'LoaderArgs'.

2 import type { LoaderArgs } from '@remix-run/server-runtime';
                ~~~~~~~~~~

[8:59:31 AM] Found 1 error. Watching for file changes.

Issue

The issue is that Remix V1's LoaderArgs was renamed to LoaderFunctionArgs in Remix V2.

Fix

Since LoaderArgs in V1 and LoaderFunctionArgs in V2 are aliases for DataFunctionArgs in both V1 and V2, changing all occurrences of "LoaderArgs" to "DataFunctionArgs" in zodix/src/parsers.ts fixes the issue.

Destructuring parsing result

Is it in your roadmap to have destructuring parsing result?
Looks cleaner I guess
Thanks

export async function action({ request }: ActionFunctionArgs) {
  const { success, error, data } = await parseFormSafe(request, schemaProfile, { parser });
  if (!success) {
    return json({ error, data: null });
  }
  return json({ error: null, data });
}

BUG: @remix-run/server-runtime peer dependency not compatible with remix v2

How to reproduce:

Step 1: Setup a fresh Remix project and install zodix

npx create-remix/latest
cd remix
npm i zodix

Expected behavior:

added 1 package, and audited 831 packages in 1s

246 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities

Actual behavior:

npm ERR! code ERESOLVE
npm ERR! ERESOLVE could not resolve
npm ERR!
npm ERR! While resolving: remix@undefined
npm ERR! Found: @remix-run/[email protected]
npm ERR! node_modules/@remix-run/server-runtime
npm ERR!   @remix-run/server-runtime@"2.0.1" from @remix-run/[email protected]
npm ERR!   node_modules/@remix-run/dev
npm ERR!     dev @remix-run/dev@"^2.0.1" from the root project
npm ERR!   @remix-run/server-runtime@"2.0.1" from @remix-run/[email protected]
npm ERR!   node_modules/@remix-run/node
npm ERR!     @remix-run/node@"2.0.1" from @remix-run/[email protected]
npm ERR!     node_modules/@remix-run/express
npm ERR!       @remix-run/express@"2.0.1" from @remix-run/[email protected]
npm ERR!       node_modules/@remix-run/serve
npm ERR!         peerOptional @remix-run/serve@"^2.0.1" from @remix-run/[email protected]
npm ERR!         node_modules/@remix-run/dev
npm ERR!         1 more (the root project)
npm ERR!     @remix-run/node@"2.0.1" from @remix-run/[email protected]
npm ERR!     node_modules/@remix-run/serve
npm ERR!       peerOptional @remix-run/serve@"^2.0.1" from @remix-run/[email protected]
npm ERR!       node_modules/@remix-run/dev
npm ERR!         dev @remix-run/dev@"^2.0.1" from the root project
npm ERR!       1 more (the root project)
npm ERR!     1 more (the root project)
npm ERR!   1 more (@remix-run/react)
npm ERR!
npm ERR! Could not resolve dependency:
npm ERR! zodix@"*" from the root project
npm ERR!
npm ERR! Conflicting peer dependency: @remix-run/[email protected]
npm ERR! node_modules/@remix-run/server-runtime
npm ERR!   peer @remix-run/server-runtime@"1.x" from [email protected]
npm ERR!   node_modules/zodix
npm ERR!     zodix@"*" from the root project
npm ERR!
npm ERR! Fix the upstream dependency conflict, or retry
npm ERR! this command with --force or --legacy-peer-deps
npm ERR! to accept an incorrect (and potentially broken) dependency resolution.

Workaround

npm i zodix --legacy-peer-deps

Issue and fix:

The @remix-run/server-runtime peer-dependency in package.json only supports 1.x. Updating to the following should fix.

  "peerDependencies": {
    "@remix-run/server-runtime": "1.x || 2.x",
    ...
  },

Release v0.4.2

As described in #34 v0.4.x currently has a breaking change causing parseForm to throw ZodErrors rather than a Response, but there hasn't been a release since the PR was merged

[BUG] TypeError: keyValidator._parse is not a function

using separate zod schema doen't work

🚀 ~ action ~ error TypeError: keyValidator._parse is not a function
    at ZodObject._parse (types.js:1188:37)
    at ZodObject._parseSync (types.js:109:29)
    at ZodObject.safeParse (types.js:139:29)
    at ZodObject.parse (types.js:120:29)
    at Object.parseForm (index.js:50:22)
    at async action (main.tsx:25:20)
    at async callLoaderOrAction (router.ts:2530:14)
    at async handleAction (router.ts:981:16)
    at async startNavigation (router.ts:904:26)
    at async Object.navigate (router.ts:784:12)

here the repo for reproduce: zodix-rr-reproduce

Ability to parse array from FormData

The docs include an example on how to parse multiple SearchParams with the same name, but there doesn't appear to be support for parsing an array from formData entries with the same name.

Add `.parseAction()` and `.parseLoader()`

These could combine the parsing of loaders/actions that use both params and query. Could look something like:

export async function loader(args: LoaderArgs) {
  const { id, count } = zx.parseLoader(args, {
    params: { id: z.string() },
    query: { count: zx.NumAsString },
  });
};

Custom error messages for helpers?

Thanks for the library!
Is there a reason the helpers (like CheckboxAsString) are not functions I can call to pass extra config (like an error message)? How would I specify my own error message?

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.