Skip to content
This repository has been archived by the owner on Dec 19, 2022. It is now read-only.

[RFC] Chainable resolvers #33

Merged
merged 25 commits into from
Apr 11, 2022
Merged

[RFC] Chainable resolvers #33

merged 25 commits into from
Apr 11, 2022

Conversation

KATT
Copy link
Member

@KATT KATT commented Mar 31, 2022

I've let this marinate, and I think chaining for the resolver API could be nicer. I've here created a POC that you can compare side-by-side with the main branch. Closes #25 & Closes #24.

Notes

  • I have decided to not ship inferrable errors for the first iteration.
  • You will be able to CMD+Click from the client/React component and jump straight to your backend definition
  • Ignore that it's throw Error below; imagine throw TRPCError({ code: '..'})
  • You can now define input before or after your middlewares run, it's up to you!
  • Considerable performance improvements of the TypeScript compilation - compiling a router with 3,000 procedures with inlined Zod-schemas takes ~10 seconds in CI.
  • Playground 👉 https://stackblitz.com/github/trpc/v10-playground/tree/katt/procedure-chains?file=src%2Fserver.ts (note that it's only TypeScript ergonomics, no actual implementation yet)

§1 Basics

§1.0 Setting up tRPC

type Context = {
  user?: {
    id: string;
    memberships: {
      organizationId: string;
    }[];
  };
};

const trpc = initTRPC<Context>();

const {
  /**
   * Builder object for creating procedures
   */
  procedure,
  /**
   * Create reusable middlewares
   */
  middleware,
  /**
   * Create a router
   */
  router,
  /**
   * Merge Routers
   */
  mergeRouters,
} = trpc;

§1.1 Creating a router

export const appRouter = trpc.router({
  queries: {
    // [...]
  },
  mutations: {
    // [...]
  },
})

§1.2 Defining a procedure

export const appRouter = trpc.router({
  queries: {
    // simple procedure without args avialable at postAll`
    postList: procedure.resolve(() => postsDb),
  }
});

Details about the procedure builder

Simplified to be more readable - see full implementation in https://github.com/trpc/v10-playground/blob/katt/procedure-chains/src/trpc/server/procedure.ts

interface ProcedureBuilder {
  /**
   * Add an input parser to the procedure.
   */
  input(
    schema: $TParser,
  ): ProcedureBuilder;
  /**
   * Add an output parser to the procedure.
   */
  output(
    schema: $TParser,
  ): ProcedureBuilder;
  /**
   * Add a middleware to the procedure.
   */
  use(
    fn: MiddlewareFunction<TParams, $TParams>,
  ): ProcedureBuilder
  /**
   * Extend the procedure with another procedure
   */
  concat(
    proc: ProcedureBuilder,
  ): ProcedureBuilder;
  resolve(
    resolver: (
      opts: ResolveOptions<TParams>,
    ) => $TOutput,
  ): Procedure;
}

§1.3 Adding input parser

Note that I'll skip the trpc.router({ queries: /*...*/}) below here

// get post by id or 404 if it's not found
const postById = procedure
  .input(
    z.object({
      id: z.string(),
    }),
  )
  .resolve(({ input }) => {
    const post = postsDb.find((post) => post.id === input.id);
    if (!post) {
      throw new Error('NOT_FOUND');
    }
    return {
      data: postsDb,
    };
  });

§1.4 Procedure with middleware

const whoami = procedure
  .use((params) => {
    if (!params.ctx.user) {
      throw new Error('UNAUTHORIZED');
    }
    return params.next({
      ctx: {
        // User is now set on the ctx object
        user: params.ctx.user,
      },
    });
  })
  .resolve(({ ctx }) => {
    // `isAuthed()` will propagate new `ctx`
    // `ctx.user` is now `NonNullable`
    return `your id is ${ctx.user.id}`;
  });
 

§2 Intermediate 🍿

§2.1 Define a reusable middleware

const isAuthed = trpc.middleware((params) => {
  if (!params.ctx.user) {
    throw new Error('zup');
  }
  return params.next({
    ctx: {
      user: params.ctx.user,
    },
  });
});

// Use in procedure:
const whoami = procedure
  .use(isAuthed)
  .resolve(({ ctx }) => {
    // `isAuthed()` will propagate new `ctx`
    // `ctx.user` is now `NonNullable`
    return `your id is ${ctx.user.id}`;
  });

§2.2 Create a bunch of procedures that are all protected

const protectedProcedure = procedure.use(isAuthed);

export const appRouter = trpc.router({
  queries: {
    postList: protectedProcedure.resolve(() => postsDb),
    postById: protectedProcedure
      .input(
        z.object({
          id: z.string(),
        }),
      )
      .resolve(({ input }) => {
        const post = postsDb.find((post) => post.id === input.id);
        if (!post) {
          throw new Error('NOT_FOUND');
        }
        return {
          data: postsDb,
        };
      })
 }
})

§2.3 Define an output schema

procedure
      .output(z.void())
      // This will fail because we've explicitly said this procedure is `void`
      .resolve(({ input }) => {
        return'hello';
      })

§2.4 Merging routers

const postRouter = trpc.router({
  queries: {
    postList: protectedProcedure.resolve(() => postsDb),
    postById: protectedProcedure
      .input(
        z.object({
          id: z.string(),
        }),
      )
      .resolve(({ input }) => {
        const post = postsDb.find((post) => post.id === input.id);
        if (!post) {
          throw new Error('NOT_FOUND');
        }
        return {
          data: postsDb,
        };
      })
  }
})

const health = trpc.router({
  query: {
    healthz: trpc.resolve(() => 'I am alive')
  }
})

export const appRouter = trpc.mergeRouters(
  postRouter,
  health
);

§3 Advanced 🧙

Compose dynamic combos of middlewares/input parsers

/**
 * A reusable combination of an input + middleware that can be reused.
 * Accepts a Zod-schema as a generic.
 */
function isPartOfOrg<
  TSchema extends z.ZodObject<{ organizationId: z.ZodString }>,
>(schema: TSchema) {
  return procedure.input(schema).use((params) => {
    const { ctx, input } = params;
    const { user } = ctx;
    if (!user) {
      throw new Error('UNAUTHORIZED');
    }

    if (
      !user.memberships.some(
        (membership) => membership.organizationId !== input.organizationId,
      )
    ) {
      throw new Error('FORBIDDEN');
    }

    return params.next({
      ctx: {
        user,
      },
    });
  });
}



const editOrganization = procedure
  .concat(
    isPartOfOrg(
      z.object({
        organizationId: z.string(),
        data: z.object({
          name: z.string(),
        }),
      }),
    ),
  )
  .resolve(({ ctx, input }) => {
    // - User is guaranteed to be part of the organization queried
    // - `input` is of type:
      // {
      //   data: {
      //       name: string;
      //   };
      //   organizationId: string;
      // }

    // [.... insert logic here]
  });

@KATT KATT added the ❕ RFC Request for comments - please comment! label Mar 31, 2022
Copy link
Member Author

@KATT KATT left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some minor naming things I'd like input on.

Comment on lines 7 to 10
/**
* Create procedure resolver
* Builder object for creating procedures
*/
resolver: pipedResolver<TContext>(),
procedure: createProcedure<TContext>(),
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be called builder or something else perhaps?

Copy link
Member

@sachinraja sachinraja Apr 1, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think procedure is fine, maybe procedureBuilder if you want to be more clear about what it is, although I think it's clear enough already.

@KATT KATT mentioned this pull request Apr 10, 2022
60 tasks
@KATT KATT marked this pull request as ready for review April 10, 2022 16:32
Comment on lines 1 to 36
/**
* @internal
*/
export interface Params<
TContextIn = unknown,
TContextOut = unknown,
TInputIn = unknown,
TInputOut = unknown,
TOutputIn = unknown,
TOutputOut = unknown,
> {
/**
* @internal
*/
_ctx_in: TContextIn;
/**
* @internal
*/
_ctx_out: TContextOut;
/**
* @internal
*/
_output_in: TOutputIn;
/**
* @internal
*/
_output_out: TOutputOut;
/**
* @internal
*/
_input_in: TInputIn;
/**
* @internal
*/
_input_out: TInputOut;
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mmkal I guess this is what you referred to as an options-bag? :)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tried to write a reply to this on my phone but too hard! Yes this is pretty much what I was thinking. A couple of ways it could be simplified even further occur to me tho:

  1. No linear generics at all. This might prove impossible if the compiler loses track of property types somewhere along the way, but here's a simplified example:
interface Params {
  input: unknown
  output: unknown
  context: unknown
}

const getClientThatUsesSpecificParams = <P extends Params>(params: P) => {
  return {
    useInput: (input: P['input']) => 123,
    getOutput: () => params.output,
  }
}

const specific = getClientThatUsesSpecificParams({
  input: {foo: 'abc'},
  output: [123, 456],
  context: {bar: 987},
})

TypeScript doesn't need specific typeargs for TInput, TOutput, TContext to keep track of them:

image

  1. Maybe there could be an IO type to generalise the _in and _out suffixes?
interface IO<I, O> {
  in: I
  out: O
}

interface Params< 
  TContextIn = unknown,
  TContextOut = unknown,
  TInputIn = unknown,
  TInputOut = unknown,
  TOutputIn = unknown,
  TOutputOut = unknown,
> {
  ctx: IO<TContextIn, TContextOut>
  input: IO<TInputIn, TInputOut>
  output: IO<TOutputIn, TOutputOut>
}
  1. Both together!?!!?
interface IO {
  input: unknown
  output: unknown
}

interface Params {
  ctx: IO
  input: IO
  output: IO
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made #35. Feel free to do PR, I have some decent testing and it's easy to run with yarn dev.

These are all internal types so easy to refactor later too, but would be nice to have it clean

@KATT KATT enabled auto-merge (squash) April 11, 2022 21:16
@KATT KATT merged commit 5dfa832 into main Apr 11, 2022
@KATT KATT deleted the katt/procedure-chains branch April 11, 2022 21:17
@KATT KATT mentioned this pull request Apr 11, 2022
@mmkal
Copy link
Contributor

mmkal commented Apr 11, 2022

I love this

@KATT KATT mentioned this pull request Apr 11, 2022
@yume-chan
Copy link

It looks so good! I only have two minor questions:

  1. Will mergeRouters accept prefix like it does now, like
export const appRouter = trpc.mergeRouters({
  post: postRouter
}, health);
  1. Is it possible to concat mutliple inputs? For example maybe I have another isPartOfTeam input + middleware, and I want to use it with isPartOfOrg? If yes, will the order of input and use affect the actual validation? Say in procedure.input(a).use(b).input(c).resolve(d), both b and c will fail, what error would be returned?

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
❕ RFC Request for comments - please comment!
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Chapter 2) The Resolver API + middlewares Chapter 1) The Router API
4 participants