diff --git a/.gitignore b/.gitignore index f160c644..855ea902 100644 --- a/.gitignore +++ b/.gitignore @@ -14,9 +14,4 @@ tmp lerna-debug.log .env -packages/create-bison-app/dist/ - -# symlinked files in the template directory for development -packages/create-bison-app/template/types -packages/create-bison-app/template/api.graphql -packages/create-bison-app/template/types.ts \ No newline at end of file +packages/create-bison-app/dist/ \ No newline at end of file diff --git a/docs/devWorkflow.md b/docs/devWorkflow.md index 213746f9..7d965074 100644 --- a/docs/devWorkflow.md +++ b/docs/devWorkflow.md @@ -2,27 +2,28 @@ You're not required to follow this exact workflow, but we've found it gives a good developer experience. -We start from the API and then create the frontend. The reason for this is that Bison will generate types for your GraphQL operations which you will leverage in your components on the frontend. +We start from the API and then create the frontend. The reason for this is that Bison will infer types for your API procedures which you will leverage in your components on the frontend. ## API -### Generate a new GraphQL module +### Generate a new tRPC router -To generate a new GraphQL module we can run the command `yarn g:graphql MODULE_NAME`, replacing MODULE_NAME with the name of your module. In our example, we'll be using the concept of an `Organization`. +To generate a new tRPC router we can run the command `yarn g:trpc ROUTER_NAME`, replacing ROUTER_NAME with the name of your router. In our example, we'll be using the concept of an `Organization`. ```shell -yarn g:graphql organization +yarn g:trpc organization ``` This should output something like: ```shell yarn run v1.22.10 -$ hygen graphql new --name organization +$ hygen trpc new --name organization Loaded templates: _templates - added: graphql/organization.ts - inject: graphql/schema.ts + added: server/routers/organization.ts + inject: server/routers/_app.ts + inject: server/routers/_app.ts ✨ Done in 0.34s. ``` @@ -62,25 +63,21 @@ model User { Note that we have defined `organizations` as `Organization[]`. -### Run your migrations - -This is currently a two step process, 1) generate the migration and 2) execute the migration - -**Generate the migration** +### Generate and execute the migration ```shell -yarn g:migration +yarn db:migrate ``` -**Execute the migration** +If you want to create the migration without executing it, run ```shell -yarn db:migrate +yarn db:migrate --create-only ``` -## Write a type, query, input, or mutation using [Nexus](https://nexusjs.org/guides/schema) +## Write a query or mutation using [tRPC](https://trpc.io) -[See Nexus Examples](./nexus-examples.md) +[See tRPC Examples](./trpc-examples.md) ## Create a new request test @@ -90,58 +87,31 @@ yarn g:test:request ## Run `yarn test` to start the test watcher -1. Add tests cases and update schema code accordingly. The GraphQL playground (localhost:3000/api/graphql) can be helpful to form the proper queries to use in tests. - -1. `types.ts` and `api.graphql` should update automatically as you change files. Sometimes it's helpful to open these as a sanity check before moving on to the frontend code. +Add tests cases and update tRPC code accordingly. ## Frontend 1. Generate a new page using `yarn g:page` 1. Generate a new component using `yarn g:component` 1. If you need to fetch data in your component, use a cell. Generate one using `yarn g:cell` -1. To generate a typed GraphQL query, simply add it to the component or page: - -```ts -export const SIGNUP_MUTATION = gql` - mutation signup($data: SignupInput!) { - signup(data: $data) { - token - user { - id - } - } - } -`; -``` - -5. Use the newly generated hooks from Codegen instead of the typical `useQuery` or `useMutation` hook. For the example above, that would be `useSignupMutation`. You'll now have a fully typed response to work with! +1. Use the hooks from tRPC, such as `trpc.user.signup.useMutation()`. You'll now have a fully typed response to work with! ```tsx -import { User, useMeQuery } from './types'; - -// adding this will auto-generate a custom hook in ./types. -export const ME_QUERY = gql` - query me { - me { - id - email - } - } -`; - -// an example of taking a user as an argument with optional attributes -function noIdea(user: Partial) { +import { trpc, inferQueryOutput } from '@/lib/trpc'; + +// an example of inferring the output of a query +function noIdea(user: Partial>) { console.log(user.email); } function fakeCell() { // use the generated hook - const { data, loading, error } = useMeQuery(); + const { data, isLoading, isError } = trpc.user.me.useQuery(); - if (loading) return ; + if (isLoading) return ; - // data.user will be fully typed - return + // data will be fully typed + return } ``` diff --git a/docs/faq.md b/docs/faq.md deleted file mode 100644 index dc535f44..00000000 --- a/docs/faq.md +++ /dev/null @@ -1,12 +0,0 @@ -# FAQ - -## Where can I find the generated GraphQL schema, React hooks, and typings? - -- Types and hooks for GraphQL queries and mutations will be generated in `./types.ts`. You can run `yarn codegen` to generate this file. -- Types for [Nexus](https://nexusjs.org/) will be generated in `./types/nexus.d.ts` and the GraphQL schema will be generated in `./api.graphql`. You can run `yarn build:nexus` to generate these files. - -When running the dev server, `yarn dev`, these types are automatically generated on file changes. You can also run `yarn build:types` to generate these files. - -## Why can't VSCode find newly generated types, even though they are in the generated file? - -Try reopening VSCode. diff --git a/docs/nexus-examples.md b/docs/nexus-examples.md deleted file mode 100644 index 03791c4c..00000000 --- a/docs/nexus-examples.md +++ /dev/null @@ -1,1280 +0,0 @@ -# Nexus Examples - -## Disclaimer - -This is not perfect and still a work in progress as we refine and iterate throughout our own applications. As Nexus and Prisma evolve so will these examples. :slight_smile: - -Found a better pattern? Let us know and open a PR! - -The goal here is to provide working examples of EB naming best practices, and relational nexus inputs that pair with Prisma. - -- [Nexus Examples](#nexus-examples) - - [Disclaimer](#disclaimer) - - [Context](#context) - - [Module References](#module-references) - - [Module Definitions and Joins](#module-definitions-and-joins) - - [Naming Conventions](#naming-conventions) - - [Naming InputTypes](#naming-inputtypes) - - [Naming Mutation Fields](#naming-mutation-fields) - - [Naming Query Fields](#naming-query-fields) - - [Shared Nexus Types](#shared-nexus-types) - - [One to One](#one-to-one) - - [Read/Filter (1-1)](#readfilter-1-1) - - [Create (1-1)](#create-1-1) - - [Update (1-1)](#update-1-1) - - [One to Many](#one-to-many) - - [Read/Filter (1-n)](#readfilter-1-n) - - [Create (1-n)](#create-1-n) - - [Update (1-n)](#update-1-n) - - [Many To Many](#many-to-many) - - [Read (n-n)](#read-n-n) - - [Create (n-n)](#create-n-n) - - [Update (n-n)](#update-n-n) - -## Context - -In this example doc, we'll follow along with the following prisma schema where: - -- A user requires a profile (one-to-one) -- A User has many skills (many-to-many) -- A User has many posts (one-to-many) - -
- Prisma Schema Example - -```javascript -model Profile { - id String @id @default(cuid()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - firstName String - lastName String - user User? -} - -model User { - id String @id @default(cuid()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - email String @unique - - profileId String - profile Profile @relation(fields: [profileId], references: [id]) - skills Skill[] - posts Post[] -} - -model Skill { - id String @id @default(cuid()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - name String @unique - description String? - archived Boolean @default(false) - users User[] -} - -model Post { - id String @id @default(cuid()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - title String @unique - body String? - userId String - user User @relation(fields: [userId], references: [id]) -} - -``` - -
- -## Module References - -
- - Full Profile Module Example - - -```typescript -import { inputObjectType, objectType } from 'nexus'; - -// Profile Type -export const Profile = objectType({ - name: 'Profile', - description: 'A User Profile', - definition(t) { - t.nonNull.id('id'); - t.nonNull.date('createdAt'); - t.nonNull.date('updatedAt'); - t.nonNull.string('firstName'); - t.nonNull.string('lastName'); - t.nonNull.field('user', { - type: 'User', - resolve: (parent, _, context) => { - return context.prisma.profile - .findUnique({ - where: { id: parent.id }, - }) - .user(); - }, - }); - - t.string('fullName', { - description: 'The first and last name of a user', - resolve({ firstName, lastName }) { - return [firstName, lastName].filter((n) => Boolean(n)).join(' '); - }, - }); - }, -}); - -export const ProfileOrderByInput = inputObjectType({ - name: 'ProfileOrderByInput', - description: 'Order Profile by a specific field', - definition(t) { - t.field('firstName', { type: 'SortOrder' }); - t.field('lastName', { type: 'SortOrder' }); - }, -}); - -export const ProfileWhereUniqueInput = inputObjectType({ - name: 'ProfileWhereUniqueInput', - description: 'Input to find Profiles based on unique fields', - definition(t) { - t.id('id'); - }, -}); - -export const ProfileWhereInput = inputObjectType({ - name: 'ProfileWhereInput', - description: 'Input to find Profiles based on other fields', - definition(t) { - t.field('firstName', { type: 'StringFilter' }); - t.field('lastName', { type: 'StringFilter' }); - }, -}); - -export const ProfileCreateInput = inputObjectType({ - name: 'ProfileCreateInput', - description: 'Input to create a Profile', - definition(t) { - t.nonNull.string('firstName'); - t.nonNull.string('lastName'); - }, -}); - -export const ProfileUpdateInput = inputObjectType({ - name: 'ProfileUpdateInput', - description: 'Input to update a Profile', - definition(t) { - t.string('firstName'); - t.string('lastName'); - }, -}); - -export const ProfileRelationInput = inputObjectType({ - name: 'ProfileRelationInput', - description: 'Prisma relational input', - definition(t) { - t.field('create', { type: 'ProfileCreateInput' }); - t.field('update', { type: 'ProfileUpdateInput' }); - }, -}); - -``` - -
- -
- - Full User Module Example - - -```typescript -import { objectType, queryField, mutationField, inputObjectType, arg, nonNull, enumType, list } from 'nexus'; -import { Role } from '@prisma/client'; -import { UserInputError } from 'apollo-server-micro'; - -import { appJwtForUser, comparePasswords, hashPassword } from '../../services/auth'; -import { isAdmin } from '../../services/permissions'; - -// User Type -export const User = objectType({ - name: 'User', - description: 'A User', - definition(t) { - t.nonNull.id('id'); - t.nonNull.date('createdAt'); - t.nonNull.date('updatedAt'); - t.nonNull.string('email'); - - t.nonNull.list.nonNull.field('skills', { - type: 'Skill', - resolve: async (parent, _, context) => { - return context.prisma.user - .findUnique({ - where: { id: parent.id }, - }) - .skills(); - }, - }); - - t.field('profile', { - type: 'Profile', - resolve: (parent, _, context) => { - return context.prisma.user - .findUnique({ - where: { id: parent.id }, - }) - .profile(); - }, - }); - - t.list.field('posts', { - type: 'Post', - resolve: async (parent, _, context) => { - return context.prisma.user - .findUnique({ - where: { id: parent.id }, - }) - .posts(); - }, - }); - }, -}); - -// Auth Payload Type -export const AuthPayload = objectType({ - name: 'AuthPayload', - description: 'Payload returned if login is successful', - definition(t) { - t.field('user', { type: 'User', description: 'The logged in user' }); - t.string('token', { - description: 'The current JWT token. Use in Authentication header', - }); - }, -}); - -export const MeQuery = queryField('me', { - type: 'User', - description: 'Returns the currently logged in user', - resolve: (_root, _args, ctx) => ctx.user, -}); - -export const UsersQuery = queryField('users', { - type: list('User'), - args: { - where: arg({ type: 'UserWhereInput' }), - orderBy: arg({ type: 'UserOrderByInput', list: true }), - }, - authorize: async (_root, _args, ctx) => !!ctx.user, - resolve: async (_root, args, ctx) => { - const { where = {}, orderBy = [] } = args; - // If user is not an admin, update where.active to equal true - let updatedQuery = where; - - if (!isAdmin(ctx.user)) { - updatedQuery = { ...where, active: true }; - } - - return await ctx.db.user.findMany({ where: updatedQuery, orderBy }); - }, -}); - -export const UserQuery = queryField('user', { - type: 'User', - authorize: async (_root, _args, ctx) => !!ctx.user, - args: { - where: nonNull(arg({ type: 'UserWhereUniqueInput' })), - }, - resolve: async (_root, args, ctx) => { - const { where } = args; - return await ctx.db.user.findUnique({ where }); - }, -}); - -// Mutations - -export const LoginMutation = mutationField('login', { - type: 'AuthPayload', - description: 'Login to an existing account', - args: { data: nonNull(arg({ type: 'LoginInput' })) }, - resolve: async (_root, args, ctx) => { - const { - data: { email, password }, - } = args; - - const user = await ctx.db.user.findUnique({ where: { email } }); - - if (!user) { - throw new UserInputError(`No user found for email: ${email}`, { - invalidArgs: { email: 'is invalid' }, - }); - } - - const valid = password && user.password && comparePasswords(password, user.password); - - if (!valid) { - throw new UserInputError('Invalid password', { - invalidArgs: { password: 'is invalid' }, - }); - } - - const token = appJwtForUser(user); - - return { - token, - user, - }; - }, -}); - -export const CreateUserMutation = mustationField('createUser', { - type: 'User', - description: 'Create User for an account', - args: { - data: nonNull(arg({ type: 'UserCreateInput' })), - }, - authorize: (_root, _args, ctx) => isAdmin(ctx.user), - resolve: async (_root, args, ctx) => { - const { data } = args; - const existingUser = await ctx.db.user.findUnique({ where: { email: data.email } }); - - if (existingUser) { - throw new UserInputError('Email already exists.', { - invalidArgs: { email: 'already exists' }, - }); - } - - // force role to user and hash the password - const updatedArgs = { - data: { - ...data, - password: hashPassword(data.password), - }, - }; - - const user = await ctx.db.user.create(updatedArgs); - - return user; - }, -}); - -export const UpdateUserMutation = mutationField('updateUser', { - type: 'User', - description: 'Updates a User', - authorize: (_root, _args, ctx) => isAdmin(ctx.user), - args: { - where: nonNull(arg({ type: 'UserWhereUniqueInput' })), - data: nonNull(arg({ type: 'UpdateUserInput' })), - }, - resolve: async (_root, args, ctx) => { - const { where, data } = args; - return await ctx.db.user.update({ where, data }); - }, -}); - -// Manually added types that were previously in the prisma plugin -export const UserRole = enumType({ - name: 'Role', - members: Object.values(Role), -}); - -export const UserRoleFilterInput = inputObjectType({ - name: 'UserRoleFilterInput', - description: 'Matches Prisma Relation Filter for Enum Role', - definition(t) { - t.field('equals', { type: 'Role', list: true }); - t.field('has', { type: 'Role' }); - t.field('hasSome', { type: 'Role', list: true }); - t.field('hasEvery', { type: 'Role', list: true }); - t.field('isEmpty', { type: 'Boolean' }); - }, -}); - -export const UserOrderByInput = inputObjectType({ - name: 'UserOrderByInput', - description: 'Order users by a specific field', - definition(t) { - t.field('active', { type: 'SortOrder' }); - t.field('createdAt', { type: 'SortOrder' }); - t.field('email', { type: 'SortOrder' }); - t.field('roles', { type: 'SortOrder' }); - t.field('updatedAt', { type: 'SortOrder' }); - // NOTE: Relational sorts not yet supported by prisma - IN PREVIEW - // https://www.prisma.io/docs/concepts/components/prisma-client/filtering-and-sorting#sort-by-relation-preview - }, -}); - -export const UserWhereUniqueInput = inputObjectType({ - name: 'UserWhereUniqueInput', - description: 'Input to find users based on unique fields', - definition(t) { - t.id('id'); - t.string('email'); - t.field('profile', { type: 'ProfileWhereUniqueInput' }); - }, -}); - -export const UserWhereInput = inputObjectType({ - name: 'UserWhereInput', - description: 'Input to find users based other fields', - definition(t) { - t.field('email', { type: 'StringFilter' }); - t.boolean('active'); - t.field('skills', { type: 'SkillRelationFilterInput' }); - t.field('roles', { type: 'UserRoleFilterInput' }); - t.field('profile', { type: 'ProfileWhereInput' }); - }, -}); - -export const UserCreateInput = inputObjectType({ - name: 'UserCreateInput', - description: 'Input to Add a new user', - definition(t) { - t.nonNull.string('email'); - t.nonNull.string('password'); - t.nonNull.list.nonNull.field('roles', { type: 'Role' }); - t.boolean('active'); - t.field('skills', { type: 'SkillRelationInput' }); - t.nonNull.field('profile', { type: 'ProfileRelationInput' }); - }, -}); - -export const UpdateUserInput = inputObjectType({ - name: 'UpdateUserInput', - description: 'Input used to update a user', - definition: (t) => { - t.boolean('active'); - t.field('skills', { type: 'SkillRelationInput' }); - t.field('profile', { type: 'ProfileRelationInput' }); - }, -}); - -export const LoginInput = inputObjectType({ - name: 'LoginInput', - description: 'Input required to login a user', - definition: (t) => { - t.nonNull.string('email'); - t.nonNull.string('password'); - }, -}); -``` - -
- -
- - Full Skill Module Example - - -```typescript -import { objectType, queryField, mutationField, inputObjectType, list, arg, nonNull } from 'nexus'; - -import { isAdmin } from '../../services/permissions'; - -// Skill Type -export const Skill = objectType({ - name: 'Skill', - description: 'A Skill', - definition(t) { - t.nonNull.id('id'); - t.nonNull.date('createdAt'); - t.nonNull.date('updatedAt'); - t.nonNull.string('name'); - t.string('description'); - t.nonNull.boolean('archived'); - - t.field('users', { - type: list('User'), - resolve: async (parent, _, context) => { - return context.prisma.skill.findUnique({ where: { id: parent.id } }).users(); - }, - }); - }, -}); - -export const SkillsQuery = queryField('skills', { - type: list('Skill'), - authorize: (_root, _args, ctx) => !!ctx.user, - args: { - where: arg({ type: 'SkillWhereInput' }), - orderBy: arg({ type: 'SkillOrderByInput', list: true }), - }, - description: 'Returns found skills', - resolve: async (_root, args, ctx) => { - const { where = {}, orderBy = [] } = args; - - return await ctx.db.skill.findMany({ where, orderBy }); - }, -}); - -export const SkillQuery = queryField('skill', { - type: 'Skill', - description: 'Returns a specific Skill', - authorize: (_root, _args, ctx) => !!ctx.user, - args: { - where: nonNull(arg({ type: 'SkillWhereUniqueInput' })), - }, - resolve: (_root, args, ctx) => { - const { where } = args; - return ctx.prisma.skill.findUnique({ where }); - }, -}); - -export const CreateSkillMutation = mutationField('createSkill', { - type: 'Skill', - description: 'Creates a Skill', - authorize: (_root, _args, ctx) => isAdmin(ctx.user), - args: { - data: nonNull(arg({ type: 'SkillCreateInput' })), - }, - resolve: async (_root, args, ctx) => { - const { data } = args; - const existingSkill = await ctx.db.skill.findUnique({ where: { name: data.name } }); - - if (existingSkill) { - throw new Error('Skill already exists.'); - } - - return await ctx.db.skill.create(args); - }, -}); - -export const UpdateSkillMutation = mutationField('updateSkill', { - type: 'Skill', - description: 'Updates a Skill', - authorize: (_root, _args, ctx) => isAdmin(ctx.user), - args: { - where: nonNull(arg({ type: 'SkillWhereUniqueInput' })), - data: nonNull(arg({ type: 'UpdateSkillInput' })), - }, - resolve: async (_root, args, ctx) => { - const { where, data } = args; - return await ctx.db.skill.update({ where, data }); - }, -}); - -// MUTATION INPUTS -export const SkillCreateInput = inputObjectType({ - name: 'SkillCreateInput', - description: 'Input used to create a skill', - definition: (t) => { - t.nonNull.string('name'); - t.string('description'); - t.string('id'); - t.boolean('archived'); - }, -}); - -export const UpdateSkillInput = inputObjectType({ - name: 'UpdateSkillInput', - description: 'Input used to update a skill', - definition: (t) => { - t.string('name'); - t.boolean('archived'); - t.string('description'); - }, -}); - -// QUERY INPUTS -export const SkillOrderByInput = inputObjectType({ - name: 'SkillOrderByInput', - description: 'Order skill by a specific field', - definition(t) { - t.field('name', { type: 'SortOrder' }); - }, -}); - -export const SkillWhereUniqueInput = inputObjectType({ - name: 'SkillWhereUniqueInput', - description: 'Input to find skills based on unique fields', - definition(t) { - t.id('id'); - t.string('name'); - }, -}); - -export const SkillWhereInput = inputObjectType({ - name: 'SkillWhereInput', - description: 'Input to find skills based on other fields', - definition(t) { - t.field('name', { type: 'StringFilter' }); - t.boolean('archived'); - }, -}); - -export const SkillRelationFilterInput = inputObjectType({ - name: 'SkillRelationFilterInput', - description: 'Input matching prisma relational filters for Skill', - definition(t) { - // NOTE: 'every' returns users with empty list - Unexpected - // t.field('every', { type: 'SkillWhereInput' }); - t.field('none', { type: 'SkillWhereInput' }); - t.field('some', { type: 'SkillWhereInput' }); - }, -}); - -export const SkillRelationInput = inputObjectType({ - name: 'SkillRelationInput', - description: 'Input matching prisma relational connect for Skill', - definition(t) { - t.list.field('connect', { type: 'SkillWhereUniqueInput' }); - //note: for now, using set instead of disconnect but could leverage later - // t.list.field('disconnect', { type: 'SkillWhereUniqueInput' }); - t.list.field('set', { type: 'SkillWhereUniqueInput' }); - }, -}); -``` - -
- -
- Full Post Module Example - -```typescript -import { inputObjectType, objectType } from 'nexus'; - -// Post Type -export const Post = objectType({ - name: 'Post', - description: 'A Post', - definition(t) { - t.nonNull.id('id'); - t.nonNull.date('createdAt'); - t.nonNull.date('updatedAt'); - t.nonNull.string('title'); - t.string('body'); - t.nonNull.field('user', { - type: 'User', - resolve: (parent, _, context) => { - return context.prisma.user - .findUnique({ - where: { id: parent.user_id }, - }) - }, - }); - }, -}); - -export const PostOrderByInput = inputObjectType({ - name: 'PostOrderByInput', - description: 'Order Post by a specific field', - definition(t) { - t.field('title', { type: 'SortOrder' }); - }, -}); - -export const PostWhereUniqueInput = inputObjectType({ - name: 'PostWhereUniqueInput', - description: 'Input to find Posts based on unique fields', - definition(t) { - t.id('id'); - }, -}); - -export const PostRelationInput = inputObjectType({ - name: 'PostRelationInput', - description: 'Prisma relational input', - definition(t) { - t.list.field('create', { type: 'PostCreateInput' }); - t.list.field('update', { type: 'PostUpdateInput' }); - }, -}); - -export const PostCreateInput = inputObjectType({ - name: 'PostCreateInput', - description: 'Input to create a Post', - definition(t) { - t.nonNull.string('title'); - ... - }, -}); -``` - -
- -## Module Definitions and Joins - -Rather than build the select, includes, etc. within our graphql query. When asking for a related field we have chosen to add a custom resolver on the parent. You'll see `User` has a field for `profile`, `skills`, and `posts` and is resolved with parent context. For this model, we've also chosen to make some of these nonNullable, stating that a `User` should have at least one `Skill` for example. - -```typescript -export const User = objectType({ - name: 'User', - description: 'A User', - definition(t) { - t.nonNull.id('id'); - t.nonNull.date('createdAt'); - t.nonNull.date('updatedAt'); - t.nonNull.string('email'); - - t.nonNull.list.nonNull.field('skills', { - type: 'Skill', - resolve: async (parent, _, context) => { - return context.prisma.user - .findUnique({ - where: { id: parent.id }, - }) - .skills(); - }, - }); - - t.field('profile', { - type: 'Profile', - resolve: (parent, _, context) => { - return context.prisma.user - .findUnique({ - where: { id: parent.id }, - }) - .profile(); - }, - }); - - t.list.field('posts', { - type: 'Post', - resolve: async (parent, _, context) => { - return context.prisma.user - .findUnique({ - where: { id: parent.id }, - }) - .posts(); - }, - }); - }, -}); -``` - -## Naming Conventions - -Iterating through, we have landed on a few patterns in naming our types. While these are not set in stone, they are recommended to stay consistent. - -### Naming InputTypes - -- `WhereInput` -- `WhereUniqueInput` -- `CreateInput` -- `UpdateInput` -- `RelationInput` -- `RelationFilterInput` _(NOTE: input changes based on association)_ -- `OrderByInput` - -### Naming Mutation Fields - -- `Mutation` -Examples: `UserCreateMutation`, `UserLoginMutation` - -### Naming Query Fields - -- `` -- `` -Examples: `UserQuery`, `UsersQuery` - -## Shared Nexus Types - -These are common types to match Prisma, found in: -`graphql > modules > shared.ts` - -- SortOrder -- StringFilter - -## One to One - -### Read/Filter (1-1) - -Looking at our `Users` query, our where filters are defined by `UserWhereInput`. -Within that you'll see for one-to-one relations we can simply leverage another models `WhereInput` to find like items. - -```typescript -const variables = { - where: { - profile: { - firstName: { - equals: "Matt" - } - } - } -} -``` - -```typescript -export const UsersQuery = queryField('users', { - type: list('User'), - args: { - where: arg({ type: 'UserWhereInput' }), - orderBy: arg({ type: 'UserOrderByInput', list: true }), - }, - - ... - - return await ctx.db.user.findMany({ where, orderBy }); -}); - -export const UserWhereInput = inputObjectType({ - name: 'UserWhereInput', - description: 'Input to find users based other fields', - definition(t) { - ... - t.field('profile', { type: 'ProfileWhereInput' }); - }, -}); -``` - -```typescript -export const ProfileWhereInput = inputObjectType({ - name: 'ProfileWhereInput', - description: 'Input to find Profiles based on other fields', - definition(t) { - t.field('firstName', { type: 'StringFilter' }); - t.field('lastName', { type: 'StringFilter' }); - }, -}); -``` - -### Create (1-1) - -For our current `User` model, you'll notice Profile is required for user creation. To do this we match Prisma's `create` key for relationships to a new input type `ProfileRelationInput`, giving us the following. - -```typescript -const variables = { - data: { - email: "bob@gmail.com", - profile: { - create: { - firstName: "Bob", - lastName: "Smith" - } - } - } -} -``` - -```typescript -export const CreateUserMutation = mustationField('createUser', { - type: 'User', - description: 'Create User for an account', - args: { - data: nonNull(arg({ type: 'UserCreateInput' })), - }, - authorize: (_root, _args, ctx) => isAdmin(ctx.user), - resolve: async (_root, args, ctx) => { - const { data } = args; - - ... other logic - - return await ctx.db.user.create(data); - }, -}); - -export const UserCreateInput = inputObjectType({ - name: 'UserCreateInput', - description: 'Input to Add a new user', - definition(t) { - t.nonNull.field('profile', { type: 'ProfileRelationInput' }); - }, -}); -``` - -```typescript -export const ProfileRelationInput = inputObjectType({ - name: 'ProfileRelationInput', - description: 'Prisma relational input', - definition(t) { - t.field('create', { type: 'ProfileCreateInput' }); - t.field('update', { type: 'ProfileUpdateInput' }); - }, -}); - -export const ProfileCreateInput = inputObjectType({ - name: 'ProfileCreateInput', - description: 'Input to create a Profile', - definition(t) { - t.nonNull.string('firstName'); - t.nonNull.string('lastName'); - }, -}); -``` - -### Update (1-1) - -Our update method for 1-1 is very similar to create. We match Prisma's `update` key and create an `UpdateInput` to tag along for the ride. In this instance we have a `ProfileUpdateInput` that can handle first name, last name updates. - -```typescript -const variables = { - where: {id: user.id}, - data: { - profile: { - update: { - firstName: "Jane", - lastName: "Doe" - } - } - } -} -``` - -```typescript -export const UpdateUserMutation = mutationField('updateUser', { - type: 'User', - description: 'Updates a User', - authorize: (_root, _args, ctx) => isAdmin(ctx.user), - args: { - where: nonNull(arg({ type: 'UserWhereUniqueInput' })), - data: nonNull(arg({ type: 'UpdateUserInput' })), - }, - resolve: async (_root, args, ctx) => { - const { where, data } = args; - return await ctx.db.user.update({ where, data }); - }, -}); - -export const UserWhereUniqueInput = inputObjectType({ - name: 'UserWhereUniqueInput', - description: 'Input to find users based on unique fields', - definition(t) { - t.id('id'); - t.string('email'); - t.field('profile', { type: 'ProfileWhereUniqueInput' }); - }, -}); - -export const UpdateUserInput = inputObjectType({ - name: 'UpdateUserInput', - description: 'Input used to update a user', - definition: (t) => { - ... - t.field('profile', { type: 'ProfileRelationInput' }); - }, -}); -``` - -```typescript -export const ProfileRelationInput = inputObjectType({ - name: 'ProfileRelationInput', - description: 'Prisma relational input', - definition(t) { - t.field('create', { type: 'ProfileCreateInput' }); - t.field('update', { type: 'ProfileUpdateInput' }); - }, -}); - -export const ProfileUpdateInput = inputObjectType({ - name: 'ProfileUpdateInput', - description: 'Input to update a Profile', - definition(t) { - t.string('firstName'); - t.string('lastName'); - }, -}); -``` - -## One to Many - -### Read/Filter (1-n) - -Similar to our 1-1 filters, you'll find a `posts` argument defined in our `UserWhereInput`. Here we leverage a similar `PostsWhereInput` - -```typescript -const variables = { - where: { - posts: { - title: { - contains: "Up and Running" - } - } - } -} -``` - -```typescript -export const UsersQuery = queryField('users', { - type: list('User'), - args: { - where: arg({ type: 'UserWhereInput' }), - orderBy: arg({ type: 'UserOrderByInput', list: true }), - }, - - ... - - return await ctx.db.user.findMany({ where, orderBy }); -}); - -export const UserWhereInput = inputObjectType({ - name: 'UserWhereInput', - description: 'Input to find users based other fields', - definition(t) { - ... - t.field('posts', { type: 'PostsWhereInput' }); - }, -}); -``` - -```typescript -export const PostsWhereInput = inputObjectType({ - name: 'PostsWhereInput', - description: 'Input to find Posts based on other fields', - definition(t) { - t.field('title', { type: 'StringFilter' }); - ... - }, -}); -``` - -### Create (1-n) - -For our one to many, we can define an option to create `n` records on User Create. -This is similar to our one-to-one creation, but adds a `.list` to the `PostRelationInput` definitions. - -```typescript -const variables = { - data: { - email: "bob@gmail.com", - posts: { - create: [ - {title: "Nexus Examples"}, {title: "Vercel.. A look back"} - ] - } - } -} -``` - -```typescript -export const CreateUserMutation = mustationField('createUser', { - type: 'User', - description: 'Create User for an account', - args: { - data: nonNull(arg({ type: 'UserCreateInput' })), - }, - authorize: (_root, _args, ctx) => isAdmin(ctx.user), - resolve: async (_root, args, ctx) => { - const { data } = args; - - ... other logic - - return await ctx.db.user.create(data); - }, -}); - -export const UserCreateInput = inputObjectType({ - name: 'UserCreateInput', - description: 'Input to Add a new user', - definition(t) { - t.nonNull.field('posts', { type: 'PostRelationInput' }); - }, -}); -``` - -```typescript -export const PostRelationInput = inputObjectType({ - name: 'PostRelationInput', - description: 'Prisma relational input', - definition(t) { - t.list.field('create', { type: 'PostCreateInput' }); - t.list.field('update', { type: 'PostUpdateInput' }); - }, -}); - -export const PostCreateInput = inputObjectType({ - name: 'PostCreateInput', - description: 'Input to create a Post', - definition(t) { - t.nonNull.string('title'); - ... - }, -}); -``` - -### Update (1-n) - -This update is better left by updating the individual records in question. - -## Many To Many - -### Read (n-n) - -To filter for a User with a specific skills, we'll be looking to match Prisma's `some` and `none` helpers. -We do this by building out a `RelationalFilterInput` for `Skills` and applying to our `WhereInput` on `User`. - -```typescript - const variables = { - data: { - where: { - skills: { - some: {id: skill.id} - } - } - } - } -``` - -```typescript -export const SkillRelationFilterInput = inputObjectType({ - name: 'SkillRelationFilterInput', - description: 'Input matching prisma relational filters for Skill', - definition(t) { - // NOTE: 'every' returns users with empty list - Unexpected - // t.field('every', { type: 'SkillWhereInput' }); - t.field('none', { type: 'SkillWhereInput' }); - t.field('some', { type: 'SkillWhereInput' }); - }, -}); -``` - -```typescript - export const UsersQuery = queryField('users', { - type: list('User'), - args: { - where: arg({ type: 'UserWhereInput' }), - orderBy: arg({ type: 'UserOrderByInput', list: true }), - }, - authorize: async (_root, _args, ctx) => !!ctx.user, - resolve: async (_root, args, ctx) => { - const { where = {}, orderBy = [] } = args; - return await ctx.db.user.findMany({ where, orderBy }); - }, - }); - - export const UserWhereInput = inputObjectType({ - name: 'UserWhereInput', - description: 'Input to find users based other fields', - definition(t) { - ... - t.field('skills', { type: 'SkillRelationFilterInput' }); - }, -}); -``` - -### Create (n-n) - -Sticking w/ our User/Skills example, when creating a User, I would also like to associate skills with said user. -For this we have two relational methods we can leverage: `connect` and `set`. `connect` will create the associations needed in the JoinTable. `set` will overwrite any previous joins with the new array given. Both of these require a `SkillWhereUniqueInput` to successfully map the records. For this example we've chosen to leverage `connect` for Create and `set` for Update to show how this all works. - -In the example below, you'll see we've added a `SkillRelationInput` type that gives us the `connect` and `set` options. We've also made this required with `nonNull` on our User, to force a relation on create. This translates to a GraphQL input that maps what prisma would expect. - -```javascript - const variables = { - data: { - email: "bob@echobind.com", - ..., - skills: { - connect: [ - {id: "asdfqwerty1234"}, - {name: "React"}, - ] - } - } - } -``` - -Skill Input Types - -```typescript -export const SkillWhereUniqueInput = inputObjectType({ - name: 'SkillWhereUniqueInput', - description: 'Input to find skills based on unique fields', - definition(t) { - t.id('id'); - t.string('name'); - }, -}); - - -export const SkillRelationInput = inputObjectType({ - name: 'SkillRelationInput', - description: 'Input matching prisma relational connect for Skill', - definition(t) { - t.list.field('connect', { type: 'SkillWhereUniqueInput' }); - t.list.field('set', { type: 'SkillWhereUniqueInput' }); - }, -}); -``` - -Create User Type and Mutation: - -```typescript - export const CreateUserMutation = mutationField('createUser', { - type: 'User', - description: 'Create User for an account', - args: { - data: nonNull(arg({ type: 'UserCreateInput' })), - }, - authorize: (_root, _args, ctx) => isAdmin(ctx.user), - resolve: async (_root, args, ctx) => { - const { data } = args; - const existingUser = await ctx.db.user.findUnique({ where: { email: data.email } }); - - if (existingUser) { - throw new UserInputError('Email already exists.', { - invalidArgs: { email: 'already exists' }, - }); - } - - // force role to user and hash the password - const updatedArgs = { - data: { - ...data, - password: hashPassword(data.password), - }, - }; - - const user = await ctx.db.user.create(updatedArgs); - - return user; - }, - }); - - - export const UserCreateInput = inputObjectType({ - name: 'UserCreateInput', - description: 'Input to Add a new user', - definition(t) { - ... - t.field('skills', { type: 'SkillRelationInput' }); - - }, -}); - -``` - -### Update (n-n) - -Similar to create for our updateUser call we will leverage `set` to overwrite and update the JoinTable with a list of newly expected Skills. - -```typescript -const variables = { - where: { id: user.id}, - data: { - skills: { - connect: [{id: "asdfqwerty1234"}, {name: "React"}, {name: "Elixir"}] - } - } -} -``` - -```typescript -export const UpdateUserMutation = mutationField('updateUser', { - type: 'User', - description: 'Updates a User', - authorize: (_root, _args, ctx) => isAdmin(ctx.user), - args: { - where: nonNull(arg({ type: 'UserWhereUniqueInput' })), - data: nonNull(arg({ type: 'UpdateUserInput' })), - }, - resolve: async (_root, args, ctx) => { - const { where, data } = args; - return await ctx.db.user.update({ where, data }); - }, -}); - -export const UpdateUserInput = inputObjectType({ - name: 'UpdateUserInput', - description: 'Input used to update a user', - definition: (t) => { - t.string('email'); - t.field('skills', { type: 'SkillRelationInput' }); - ... - }, -}); -``` diff --git a/docs/request-spec-examples.md b/docs/request-spec-examples.md index 1c0f0709..a5d91d32 100644 --- a/docs/request-spec-examples.md +++ b/docs/request-spec-examples.md @@ -1,129 +1,74 @@ # Request Spec Examples -When generating a graphql module w/ `yarn g:graqhql` the basic boilerplate will generate for Find, List, Create, and Update. +When generating a tRPC module w/ `yarn g:trpc` the basic boilerplate will generate for Find, List, Create, and Update. Below is an example of testing each with a `Skill` module example:
Generated Skill Module ```typescript -import { objectType, inputObjectType, queryField, mutationField, arg, list, nonNull } from 'nexus'; - -import { isAdmin } from '../../services/permissions'; - -// Skill Type -export const Skill = objectType({ - name: 'Skill', - description: 'A Skill', - definition(t) { - t.nonNull.id('id'); - t.nonNull.date('createdAt'); - t.nonNull.date('updatedAt'); - t.nonNull.string('name'); - }, +import { Prisma } from '@prisma/client'; +import { z } from 'zod'; + +import { t } from '@/server/trpc'; +import { adminProcedure, protectedProcedure } from '@/server/middleware/auth'; + +// Skill default selection +export const defaultSkillSelect = Prisma.validator()({ + id: true, + createdAt: true, + updatedAt: true, + name: true, }); -// Queries -export const findSkillsQuery = queryField('skills', { - type: list('Skill'), - authorize: (_root, _args, ctx) => !!ctx.user, - args: { - where: arg({ type: 'SkillWhereInput' }), - orderBy: arg({ type: 'SkillOrderByInput', list: true }), - }, - description: 'Returns found skills', - resolve: async (_root, args, ctx) => { - const { where = {}, orderBy = [] } = args; - - return await ctx.db.skill.findMany({ where, orderBy }); - } +export const skillRouter = t.router({ + skills: protectedProcedure + .input( + z.object({ + where: z.object({ name: z.string().optional() }).optional(), + orderBy: z + .object({ name: z.enum(['asc', 'desc']) }) + .array() + .optional(), + }) + ) + .query(async ({ ctx, input }) => { + const { where = {}, orderBy = [] } = input; + return await ctx.db.skill.findMany({ + where, + orderBy, + select: defaultSkillSelect, + }); + }), + skill: protectedProcedure + .input(z.object({ where: z.object({ id: z.string() }) })) + .query(async ({ ctx, input }) => { + const { where } = input; + return ctx.prisma.skill.findUnique({ where, select: defaultSkillSelect }); + }), + createSkill: adminProcedure + .input(z.object({ data: z.object({ name: z.string() }) })) + .mutation(async ({ ctx, input }) => { + const { data } = input; + return await ctx.db.skill.create({ data, select: defaultSkillSelect }); + }), + updateSkill: adminProcedure + .input( + z.object({ + where: z.object({ id: z.string() }), + data: z.object({ name: z.string() }), + }) + ) + .mutation(async ({ ctx, input }) => { + const { where, data } = input; + + return await ctx.db.skill.update({ + where, + data, + select: defaultSkillSelect, + }); + }), }); - -export const findUniqueSkillQuery = queryField('skill', { - type: 'Skill', - description: 'Returns a specific Skill', - authorize: (_root, _args, ctx) => !!ctx.user, - args: { - where: nonNull(arg({ type: 'SkillWhereUniqueInput' })) - }, - resolve: (_root, args, ctx) => { - const { where } = args; - return ctx.prisma.skill.findUnique({ where }) - }, -}); - -// Mutations -export const createSkillMutation = mutationField('createSkill', { - type: 'Skill', - description: 'Creates a Skill', - authorize: (_root, _args, ctx) => isAdmin(ctx.user), - args: { - data: nonNull(arg({ type: 'CreateSkillInput' })), - }, - resolve: async (_root, args, ctx) => { - return await ctx.db.skill.create(args); - } -}); - -export const updateSkillMutation = mutationField('updateSkill', { - type: 'Skill', - description: 'Updates a Skill', - authorize: (_root, _args, ctx) => isAdmin(ctx.user), - args: { - where: nonNull(arg({ type: 'SkillWhereUniqueInput'})), - data: nonNull(arg({ type: 'UpdateSkillInput' })), - }, - resolve: async (_root, args, ctx) => { - const { where, data } = args; - - return await ctx.db.skill.update({ where, data }); - } -}); - -// MUTATION INPUTS -export const CreateSkillInput = inputObjectType({ - name: 'CreateSkillInput', - description: 'Input used to create a skill', - definition: (t) => { - t.nonNull.string('name'); - }, -}); - -export const UpdateSkillInput = inputObjectType({ - name: 'UpdateSkillInput', - description: 'Input used to update a skill', - definition: (t) => { - t.nonNull.string('name'); - }, -}); - -// QUERY INPUTS -export const SkillOrderByInput = inputObjectType({ - name: 'SkillOrderByInput', - description: 'Order skill by a specific field', - definition(t) { - t.field('name', { type: 'SortOrder' }); - }, -}); - -export const SkillWhereUniqueInput = inputObjectType({ - name: 'SkillWhereUniqueInput', - description: 'Input to find skills based on unique fields', - definition(t) { - t.id('id'); - // add DB uniq fields here - // t.string('name'); - }, -}); - -export const SkillWhereInput = inputObjectType({ - name: 'SkillWhereInput', - description: 'Input to find skills based on other fields', - definition(t) { - t.field('name', { type: 'StringFilter' }); - }, -}); - ```
@@ -136,7 +81,7 @@ export const SkillWhereInput = inputObjectType({ ```typescript import { Role } from '@prisma/client'; -import { resetDB, disconnect, graphQLRequestAsUser } from '../../helpers'; +import { resetDB, disconnect, trpcRequest } from '../../helpers'; import { UserFactory } from '../../factories/user'; import { SkillFactory } from '../../factories/skill'; @@ -146,48 +91,25 @@ afterAll(async () => disconnect()); describe('skill query', () => { describe('as Role User', () => { it('can query other Skill', async () => { - const query = ` - query skill($where: SkillWhereUniqueInput!) { - skill( where: $where ) { - id - name - } - } - `; - const user = await UserFactory.create({ roles: [Role.USER] }); const record = await SkillFactory.create(); const variables = { where: { id: record.id } }; - const response = await graphQLRequestAsUser(user, { query, variables }); - - const { skill } = response.body.data; + const skill = await trpcRequest(user).skill.createSkill(variables); expect(skill.id).not.toBeNull(); expect(skill.name).toEqual(record.name); - expect('Update Generated Test').toBeNull(); }); }); describe('as Role ADMIN', () => { - it('can query a user email', async () => { - const query = ` - query skill($where: SkillWhereUniqueInput!) { - skill( where: $where ) { - id - name - } - } - `; - + it('can query a skill', async () => { const admin = await UserFactory.create({ roles: { set: [Role.ADMIN] } }); const record = await SkillFactory.create(); const variables = { where: { id: record.id } }; - const response = await graphQLRequestAsUser(admin, { query, variables }); - - const { skill } = response.body.data; + const skill = await trpcRequest(admin).skill.createSkill(variables); expect(skill.id).not.toBeNull(); expect(skill.name).toEqual(record.name); @@ -195,7 +117,6 @@ describe('skill query', () => { }); }); }); - ``` @@ -208,7 +129,7 @@ describe('skill query', () => { ```typescript import { Role } from '@prisma/client'; -import { resetDB, disconnect, graphQLRequestAsUser, graphQLRequest } from '../../helpers'; +import { resetDB, disconnect, trpcRequest } from '../../helpers'; import { UserFactory } from '../../factories/user'; import { SkillFactory } from '../../factories/skill'; @@ -218,99 +139,58 @@ afterAll(async () => disconnect()); describe('skills query', () => { describe('not logged in', () => { it('returns a Not Authorized error', async () => { - const query = ` - query skills { - skills { - id - name - } - } - `; - - const response = await graphQLRequest({ query }); - const errorMessages = response.body.errors.map((e) => e.message); - - expect(errorMessages).toEqual(['Not authorized']); + await expect(trpcRequest().skill.skills()).rejects.toThrowErrorMatchingInlineSnapshot( + `"UNAUTHORIZED"` + ); }); }); describe('As Role Admin', () => { it('Can Query Skill', async () => { - const query = ` - query skills($where: SkillWhereInput!) { - skills(where: $where) { - id - name - } - } - `; - const admin = await UserFactory.create({ roles: [Role.ADMIN] }); const record = await SkillFactory.create({ name: 'TODO' }); const record2 = await SkillFactory.create({ name: 'ASDF' }); const variables = { where: {} }; - const response = await graphQLRequestAsUser(admin, { query, variables }); + const skills = await trpcRequest(admin).skill.skills(variables); - expect(response.body.data.skills).toMatchObject([ + expect(skills.map(({ id, name }) => ({ id, name }))).toMatchObject([ { id: record.id, name: 'TODO' }, { id: record2.id, name: 'ASDF' }, ]); - - expect('Updated Generated Test').toBeNull(); }); }); describe('As Role User', () => { it('Can Query Skill', async () => { - const query = ` - query skills($where: SkillWhereInput!) { - skills(where: $where) { - id - name - } - } - `; - const user = await UserFactory.create({ roles: [Role.USER] }); const record = await SkillFactory.create({ name: 'TODO' }); const record2 = await SkillFactory.create({ name: 'ASDF' }); const variables = { where: {} }; - const response = await graphQLRequestAsUser(user, { query, variables }); + const skills = await trpcRequest(user).skill.skills(variables); - expect(response.body.data.skills).toMatchObject([ + expect(skills.map(({ id, name }) => ({ id, name }))).toMatchObject([ { id: record.id, name: 'TODO' }, { id: record2.id, name: 'ASDF' }, ]); - - expect('Updated Generated Test').toBeNull(); }); it('Can Filter Skill', async () => { - const query = ` - query skills($where: SkillWhereInput!) { - skills(where: $where) { - id - name - } - } - `; - const user = await UserFactory.create({ roles: [Role.USER] }); const record = await SkillFactory.create({ name: 'TODO' }); await SkillFactory.create({ name: 'ASDF' }); // TODO: Update with SkillWhereInput! use case const variables = { where: { name: record.name } }; - const response = await graphQLRequestAsUser(user, { query, variables }); + const skills = await trpcRequest(user).skill.skills(variables); - expect(response.body.data.skills).toMatchObject([{ id: record.id, name: 'TODO' }]); - expect('Updated Generated Test').toBeNull(); + expect(skills.map(({ id, name }) => ({ id, name }))).toMatchObject([ + { id: record.id, name: 'TODO' }, + ]); }); }); }); - ``` @@ -323,7 +203,7 @@ describe('skills query', () => { ```typescript import { Role } from '@prisma/client'; -import { resetDB, disconnect, graphQLRequestAsUser } from '../../helpers'; +import { resetDB, disconnect, trpcRequest } from '../../helpers'; import { UserFactory } from '../../factories/user'; beforeEach(async () => resetDB()); @@ -332,53 +212,25 @@ afterAll(async () => disconnect()); describe('Skill createSkill mutation', () => { describe('As a Role User', () => { it('returns Not Authorized', async () => { - const query = ` - mutation createSkill($data: SkillCreateInput!) { - createSkill(data: $data) { - id - } - } - `; - const user = await UserFactory.create({ roles: [Role.USER] }); // Insert SkillCreateInput! const variables = { data: { name: 'TODO' } }; - const response = await graphQLRequestAsUser(user, { query, variables }); - const errorMessages = response.body.errors.map((e) => e.message); - - expect(errorMessages).toMatchInlineSnapshot(` - Array [ - "Not authorized", - ] - `); - - expect('Update Generated Test').toBeNull(); + await expect( + trpcRequest(user).skill.createSkill(variables) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"UNAUTHORIZED"`); }); }); describe('As Role ADMIN', () => { it('can create a skill', async () => { - const query = ` - mutation createSkill($data: SkillCreateInput!) { - createSkill(data: $data) { - id - } - } - `; - const admin = await UserFactory.create({ roles: [Role.ADMIN] }); const variables = { data: { name: 'TODO' } }; + const skill = await trpcRequest(user).skill.createSkill(variables); - const response = await graphQLRequestAsUser(admin, { query, variables }); - const errorMessages = response.body.errors.map((e) => e.message); - const { createSkill } = response.body.data; - - expect(errorMessages).toBeNull(); - expect(createSkill.id).not.toBeNull(); - expect('Update Generated Test').toBeNull(); + expect(skill.id).not.toBeNull(); }); }); }); @@ -394,7 +246,7 @@ describe('Skill createSkill mutation', () => { ```typescript import { Role } from '@prisma/client'; -import { resetDB, disconnect, graphQLRequestAsUser } from '../../helpers'; +import { resetDB, disconnect, trpcRequest } from '../../helpers'; import { UserFactory } from '../../factories/user'; import { SkillFactory } from '../../factories/skill'; @@ -404,46 +256,22 @@ afterAll(async () => disconnect()); describe('Skill updateSkill mutation', () => { describe('As a Role User', () => { it('returns Not Authorized', async () => { - const query = ` - mutation updateSkill($data: SkillUpdateInput!) { - updateSkill(data: $data) { - id - } - } - `; - const user = await UserFactory.create({ roles: [Role.USER] }); await SkillFactory.create({ name: 'TODO' }); - //TODO: Insert SkillUpdateInput! use case const variables = { where: { name: 'TODO' }, data: { name: 'UPDATED' }, }; - const response = await graphQLRequestAsUser(user, { query, variables }); - const errorMessages = response.body.errors.map((e) => e.message); - - expect(errorMessages).toMatchInlineSnapshot(` - Array [ - "Not authorized", - ] - `); - - expect('Update Generated Test').toBeNull(); + await expect( + trpcRequest(user).skill.updateSkill(variables) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"UNAUTHORIZED"`); }); }); describe('As Role ADMIN', () => { it('can update a skill', async () => { - const query = ` - mutation updateSkill($data: SkillUpdateInput!) { - updateSkill(data: $data) { - id - } - } - `; - const admin = await UserFactory.create({ roles: [Role.ADMIN] }); await SkillFactory.create({ name: 'TODO' }); @@ -452,21 +280,16 @@ describe('Skill updateSkill mutation', () => { data: { name: 'UPDATED' }, }; - const response = await graphQLRequestAsUser(admin, { query, variables }); - const errorMessages = response.body.errors.map((e) => e.message); - const { updateSkill } = response.body.data; + const skill = await trpcRequest(admin).skill.updateSkill(variables); - expect(errorMessages).toBeNull(); - expect(updateSkill.name).toEqual('UPDATED'); - expect('Update Generated Test').toBeNull(); + expect(skill.name).toEqual('UPDATED'); }); }); }); - ``` -## Auto-Generating specs with `yarn g:graphql` +## Auto-Generating specs with `yarn g:trpc` -If you find yourself creating these often, you may wish to add template files the graphql template folder. See Closed PR for an example: [https://github.com/echobind/bisonapp/pull/186](https://github.com/echobind/bisonapp/pull/186) +If you find yourself creating these often, you may wish to add template files the trpc template folder. See Closed PR for an example (this uses GraphQL, but the principle is the same): [https://github.com/echobind/bisonapp/pull/186](https://github.com/echobind/bisonapp/pull/186) diff --git a/docs/trpc-examples.md b/docs/trpc-examples.md new file mode 100644 index 00000000..3dca15cd --- /dev/null +++ b/docs/trpc-examples.md @@ -0,0 +1,774 @@ +# tRPC Examples + +## Disclaimer + +This is not perfect and still a work in progress as we refine and iterate throughout our own applications. As tRPC and Prisma evolve so will these examples. :slight_smile: + +Found a better pattern? Let us know and open a PR! + +The goal here is to provide working examples of EB naming best practices, and relational tRPC inputs that pair with Prisma. + +- [tRPC Examples](#trpc-examples) + - [Disclaimer](#disclaimer) + - [Context](#context) + - [Router References](#router-references) + - [Module Definitions and Joins](#module-definitions-and-joins) + - [One to One](#one-to-one) + - [Read/Filter (1-1)](#readfilter-1-1) + - [Create (1-1)](#create-1-1) + - [Update (1-1)](#update-1-1) + - [One to Many](#one-to-many) + - [Read/Filter (1-n)](#readfilter-1-n) + - [Create (1-n)](#create-1-n) + - [Update (1-n)](#update-1-n) + - [Many To Many](#many-to-many) + - [Read (n-n)](#read-n-n) + - [Create (n-n)](#create-n-n) + - [Update (n-n)](#update-n-n) + +## Context + +In this example doc, we'll follow along with the following prisma schema where: + +- A user requires a profile (one-to-one) +- A User has many skills (many-to-many) +- A User has many posts (one-to-many) + +
+ Prisma Schema Example + +```javascript +model Profile { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + firstName String + lastName String + user User? +} + +model User { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + email String @unique + + profileId String + profile Profile @relation(fields: [profileId], references: [id]) + skills Skill[] + posts Post[] +} + +model Skill { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + name String @unique + description String? + archived Boolean @default(false) + users User[] +} + +model Post { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + title String @unique + body String? + userId String + user User @relation(fields: [userId], references: [id]) +} + +``` + +
+ +## Router References + +
+ + Full User Module Example + + +```typescript +import { Prisma } from '@prisma/client'; +import { z } from 'zod'; + +import { t } from '@/server/trpc'; +import { adminProcedure, protectedProcedure } from '@/server/middleware/auth'; + +import { isAdmin } from '../../services/permissions'; +import { appJwtForUser, comparePasswords, hashPassword } from '../../services/auth'; + +export const defaultUserSelect = Prisma.validator()({ + id: true, + email: true, +}); + +export const userRouter = t.router({ + me: protectedProcedure.query(async ({ ctx }) => ctx.user), + findMany: protectedProcedure + .input( + z.object({ + where: z.object({ name: z.string().optional() }).optional(), + orderBy: z + .object({ name: z.enum(['asc', 'desc']) }) + .array() + .optional(), + }) + ) + .query(async ({ ctx, input }) => { + const { where = {}, orderBy = [] } = input; + + // If user is not an admin, update where.active to equal true + let updatedQuery = where; + + if (!isAdmin(ctx.user)) { + updatedQuery = { ...where, active: true }; + } + + return await ctx.db.user.findMany({ + where: updatedQuery, + orderBy, + select: defaultUserSelect, + include: { profile: true }, + }); + }), + find: protectedProcedure.input({ where: z.object({ id: z.string().optional(), email: z.string().optional() }) }).query(async ({ ctx, input }) => { + const { where } = input; + + return await ctx.db.user.findUnique({ + where, + select: defaultUserSelect, + include: { profile: true }, + }); + }), + login: t.procedure.input(z.object({ email: z.string(), password: z.string() })).mutation(async ({ ctx, input: { email, password } }) => { + const result = await ctx.db.user.findUnique({ + where: { email }, + select: { ...defaultUserSelect, password: true }, + include: { profile: true }, + }); + + if (!result) { + throw new BisonError({ + message: `No user found for email: ${email}`, + code: 'BAD_REQUEST', + invalidArgs: { email: `No user found for email: ${email}` }, + }); + } + + const { password: userPassword, ...user } = result; + + const valid = comparePasswords(password, userPassword); + + if (!valid) { + throw new BisonError({ + message: 'Invalid password', + code: 'BAD_REQUEST', + invalidArgs: { password: 'Invalid password' }, + }); + } + + const token = appJwtForUser(user); + + return { + token, + user, + }; + }), + signup: t.procedure + .input( + z.object({ + email: z.string(), + password: z.string(), + profile: z.object({ firstName: z.string(), lastName: z.string() }), + }) + ) + .mutation(async ({ ctx, input: { email, password, profile } }) => { + const existingUser = await ctx.db.user.findUnique({ + where: { email }, + select: defaultUserSelect, + }); + + if (existingUser) { + throw new BisonError({ + message: 'Email already exists.', + code: 'BAD_REQUEST', + invalidArgs: { email: 'Email already exists.' }, + }); + } + + // force role to user and hash the password + const user = await ctx.db.user.create({ + data: { + email, + profile: { create: profile }, + roles: { set: [Role.USER] }, + password: hashPassword(password), + }, + select: defaultUserSelect, + }); + + const token = appJwtForUser(user); + + return { + user, + token, + }; + }), + create: adminProcedure + .input( + z.object({ + email: z.string(), + password: z.string(), + roles: z.array(z.nativeEnum(Role)).optional(), + profile: z.object({ firstName: z.string(), lastName: z.string() }).optional(), + }) + ) + .mutation(async ({ ctx, input: { email, password, roles = [Role.USER], profile } }) => { + const existingUser = await ctx.db.user.findUnique({ where: { email } }); + + if (existingUser) { + throw new BisonError({ + message: 'Email already exists.', + code: 'BAD_REQUEST', + invalidArgs: { email: 'Email already exists.' }, + }); + } + + // force role to user and hash the password + const updatedArgs = { + data: { + email, + roles, + profile, + password: hashPassword(password), + }, + select: defaultUserSelect, + }; + + const user = await ctx.db.user.create(updatedArgs); + + return user; + }), +}); +``` + +
+ +
+ + Full Skill Module Example + + +```typescript +import { Prisma } from '@prisma/client'; +import { z } from 'zod'; + +import { t } from '@/server/trpc'; +import { adminProcedure, protectedProcedure } from '@/server/middleware/auth'; + +// Skill default selection +export const defaultSkillSelect = Prisma.validator()({ + id: true, + createdAt: true, + updatedAt: true, + name: true, + description: true, + archived: true, +}); + +export const skillRouter = t.router({ + findMany: protectedProcedure + .input( + z.object({ + where: z.object({ name: z.string().optional(), archived: z.boolean().optional() }).optional(), + orderBy: z + .object({ name: z.enum(['asc', 'desc']) }) + .array() + .optional(), + }) + ) + .query(async ({ ctx, input }) => { + const { where = {}, orderBy = [] } = input; + return await ctx.db.skill.findMany({ + where, + orderBy, + select: defaultSkillSelect, + }); + }), + find: protectedProcedure.input(z.object({ where: z.object({ id: z.string().optional(), name: z.string.optional() }) })).query(async ({ ctx, input }) => { + const { where } = input; + return ctx.prisma.skill.findUnique({ where, select: defaultSkillSelect }); + }), + create: adminProcedure.input(z.object({ data: z.object({ name: z.string() }) })).mutation(async ({ ctx, input }) => { + const { data } = input; + return await ctx.db.skill.create({ data, select: defaultSkillSelect }); + }), + update: adminProcedure + .input( + z.object({ + where: z.object({ id: z.string() }), + data: z.object({ name: z.string() }), + }) + ) + .mutation(async ({ ctx, input }) => { + const { where, data } = input; + + return await ctx.db.skill.update({ + where, + data, + select: defaultSkillSelect, + }); + }), +}); +``` + +
+ +## Module Definitions and Joins + +Unlike GraphQL, by default the client can't specify which fields it wants. While slightly less inconvenient for the client, this makes writing robust server code easier. Instead of building DataLoaders and dealing with the N+1 problem, you can create a new, separate procedure for any data that needs to employ any joins. Within that procedure, you can craft your database queries to limit the amount of traffic and provide the best speed. + +```typescript +export const userRouter = t.router({ + // ... + getWithPosts: protectedProcedure + .input({ + where: z.object({ id: z.string().optional(), email: z.string().optional() }), + }) + .query(async ({ ctx, input }) => { + const { where } = input; + + return await ctx.db.user.findUnique({ + where, + select: defaultUserSelect, + include: { profile: true, posts: true }, + }); + }), +}); +``` + +Alternatively, you could include options in your procedure input for choosing specifically what fields or joins to make, giving the client a little more flexibility. + +## One to One + +### Read/Filter (1-1) + +Looking at our `users` procedure, our where filters are defined using an input that includes `where`. +Within that you'll see for one-to-one relations we can leverage `stringFilter` to find like items. + +```typescript +const input = { + where: { + profile: { + firstName: { + equals: 'Matt', + }, + }, + }, +}; +``` + +```typescript +const stringFilter = z.object({ + contains:z.string().optional(), + endsWith:z.string().optional(), + equals:z.string().optional(), + gt:z.string().optional(), + gte:z.string().optional(), + in:z.string().array().optional(), + lt:z.string().optional(), + lte:z.string().optional(), + notIn:z.string().array().optional(), + startsWith:z.string().optional(), +}) + +const filterProfileFields = z.object({ + firstName: stringFilter + lastName: stringFilter +}); + +export const userRouter = t.router({ + findMany: protectedProcedure + .input( + z.object({ + where: z.object({ + profile: profileFields + }).optional(), + orderBy: z + .object({ name: z.enum(['asc', 'desc']) }) + .array() + .optional(), + }) + ) + .query(async ({ ctx, input }) => { + const { where = {}, orderBy = [] } = input; + + return await ctx.db.user.findMany({ + where: updatedQuery, + orderBy, + select: defaultUserSelect, + include: { profile: true }, + }); + }), + // ... +}) +``` + +### Create (1-1) + +For our current `User` model, you'll notice Profile is required for user creation. To do this we match Prisma's `create` key for relationships to the `profileFields` definition, giving us the following. + +```typescript +const input = { + data: { + email: 'bob@gmail.com', + profile: { + create: { + firstName: 'Bob', + lastName: 'Smith', + }, + }, + }, +}; +``` + +```typescript +const profileFields = z.object({ + firstName: z.string(), + lastName: z.string() +}); + +export const userRouter = t.router({ + create: adminProcedure + .input( + z.object({ + data: z.object({ + email: z.string(); + profile: z.object({ + create: profileFields + }) + }) + }) + ) + .mutation(async ({ ctx, input }) => { + const {data} = input + // other logic + + const user = await ctx.db.user.create({data}); + + return user; + }), +}); +``` + +### Update (1-1) + +Our update method for 1-1 is very similar to create. We match Prisma's `update` key with our Zod validator. In this instance we use a `optionalProfileFields` validator that can handle first name, last name updates. + +```typescript +const variables = { + where: { id: user.id }, + data: { + profile: { + update: { + firstName: 'Jane', + lastName: 'Doe', + }, + }, + }, +}; +``` + +```typescript +const optionalProfileFields = z.object({ + firstName: z.string().optional(), + lastName: z.string().optional(), +}); + +export const userRouter = t.router({ + create: adminProcedure + .input( + z.object({ + where: z.object({ + id: z.string().optional(), + email: z.string().optional(), + }), + data: z.object({ + email: z.string().optional(), + profile: z + .object({ + update: optionalProfileFields, + }) + .optional(), + }), + }) + ) + .mutation(async ({ ctx, input }) => { + const { data, where } = input; + // other logic + + return await ctx.db.user.update({ where, data }); + }), +}); +``` + +## One to Many + +### Read/Filter (1-n) + +Similar to our 1-1 filters, you'll find a `posts` argument defined in our input. Here we leverage a similar `postFields` validator. + +```typescript +const variables = { + where: { + posts: { + title: { + contains: 'Up and Running', + }, + }, + }, +}; +``` + +```typescript +export const userRouter = t.router({ + users: protectedProcedure + .input( + z.object({ + where: z + .object({ + name: z.string().optional(), + posts: z + .object({ + title: stringFilter.optional(), + }) + .optional(), + }) + .optional(), + orderBy: z + .object({ name: z.enum(['asc', 'desc']) }) + .array() + .optional(), + }) + ) + .query(async ({ ctx, input }) => { + const { where = {}, orderBy = [] } = input; + + return await ctx.db.user.findMany({ + where, + orderBy, + select: defaultUserSelect, + include: { posts: true }, + }); + }), +}); +``` + +### Create (1-n) + +For our one to many, we can define an option to create `n` records on User Create. +This is similar to our one-to-one creation, but adds a `.array` to the post input definitions. + +```typescript +const variables = { + data: { + email: 'bob@gmail.com', + posts: { + create: [{ title: 'tRPC Examples' }, { title: 'Vercel.. A look back' }], + }, + }, +}; +``` + +```typescript +export const userRouter = t.router({ + create: adminProcedure + .input( + z.object({ + data: z.object({ + email: z.string(), + posts: z + .object({ + create: z + .object({ + title: z.string(), + }) + .array() + .optional(), + }) + .optional(), + }), + }) + ) + .mutation(async ({ ctx, input }) => { + const user = await ctx.db.user.create(input); + + return user; + }), +}); +``` + +### Update (1-n) + +This kind of update is better left to updating the individual records in question. + +## Many To Many + +### Read (n-n) + +To filter for a User with a specific skills, we'll be looking to match Prisma's `some` and `none` helpers. + +```typescript +const variables = { + data: { + where: { + skills: { + some: { id: skill.id }, + }, + }, + }, +}; +``` + +```typescript +export const userRouter = t.router({ + findMany: protectedProcedure + .input( + z.object({ + where: z + .object({ + skills: z + .object({ + some: z + .object({ + id: z.string(), + }) + .optional(), + none: z + .object({ + id: z.string(), + }) + .optional(), + }) + .optional(), + }) + .optional(), + orderBy: z + .object({ name: z.enum(['asc', 'desc']) }) + .array() + .optional(), + }) + ) + .query(async ({ ctx, input }) => { + const { where = {}, orderBy = [] } = input; + + return await ctx.db.user.findMany({ + where, + orderBy, + select: defaultUserSelect, + include: { posts: true }, + }); + }), +}); +``` + +### Create (n-n) + +Sticking w/ our User/Skills example, when creating a User, I would also like to associate skills with said user. +For this we have two relational methods we can leverage: `connect` and `set`. `connect` will create the associations needed in the JoinTable. `set` will overwrite any previous joins with the new array given. Both of these require an input to successfully map the records. For this example we've chosen to leverage `connect` for Create and `set` for Update to show how this all works. + +In the example below, our validator gives us the `connect` and `set` options. We've also made this required by omitting `optional` on our User, to force a relation on create. + +```javascript + const input = { + data: { + email: "bob@echobind.com", + ..., + skills: { + connect: [ + {id: "asdfqwerty1234"}, + {name: "React"}, + ] + } + } + } +``` + +```typescript +export const userRouter = t.router({ + create: adminProcedure + .input( + z.object({ + data: z.object({ + email: z.string(), + skills: z + .object({ + connect: z + .object({ + id: z.string().optional(), + title: z.string().optional(), + }) + .array(), + }) + .optional(), + }), + }) + ) + .mutation(async ({ ctx, input }) => { + const user = await ctx.db.user.create(input); + + return user; + }), +}); +``` + +### Update (n-n) + +Similar to create for our `update` call we will leverage `set` to overwrite and update the JoinTable with a list of newly expected Skills. + +```typescript +const variables = { + where: { id: user.id }, + data: { + skills: { + connect: [{ id: 'asdfqwerty1234' }, { name: 'React' }, { name: 'Elixir' }], + }, + }, +}; +``` + +```typescript +export const userRouter = t.router({ + create: adminProcedure + .input( + z.object({ + where: z.object({ + id: z.string(), + }), + data: z.object({ + skills: z + .object({ + connect: z + .object({ + id: z.string().optional(), + title: z.string().optional(), + }) + .array(), + }) + .optional(), + }), + }) + ) + .mutation(async ({ ctx, input }) => { + const user = await ctx.db.user.create(input); + + return user; + }), +}); +``` diff --git a/packages/create-bison-app/README.md b/packages/create-bison-app/README.md index fdba9b3d..89e9454d 100644 --- a/packages/create-bison-app/README.md +++ b/packages/create-bison-app/README.md @@ -11,9 +11,8 @@ We're always improving on this, and we welcome suggestions from the community! - [Next.js](https://nextjs.org/) - [TypeScript](https://www.typescriptlang.org/) -- GraphQL API built with [Nexus](https://nexusjs.org/) +- [tRPC](https://trpc.io) API for end-to-end type safety - [Prisma](https://www.prisma.io/) w/ Postgres -- [GraphQL Codegen](https://graphql-code-generator.com/) to generate TypeScript types (Schema types and query/mutation hooks) - [Chakra UI](https://chakra-ui.com/) - [React Hook Form](https://react-hook-form.com/) - [Cypress](https://www.cypress.io/) for E2E tests @@ -29,8 +28,7 @@ We're always improving on this, and we welcome suggestions from the community! ## Conventions - Don't copy/paste files, use generators and Hygen templates. Need to update the generator as your project evolves? they are all in the `_templates` folder. -- Use a single command to run Next, generate Nexus types, and GraphQL types for the frontend. By doing this you can ensure your types are always up-to-date. -- Don't manually write types for GraphQL responses... use the generated query hooks from GraphQL Codegen. +- Don't manually write types for tRPC procedures. Infer the types from the router definition. - All frontend pages are static by default. If you need something server rendered, just add `getServerSideProps` like you would in any Next app. ## Tradeoffs @@ -75,11 +73,7 @@ Please refer to: [Set up Postgres](/docs/postgres.md). ## Run the app locally -From the root, run `yarn dev`. This: - -- runs `next dev` to run the frontend and serverless functions locally -- starts a watcher to generate the Prisma client on schema changes -- starts a watcher to generate TypeScript types for GraphQL files +From the root, run `yarn dev`. This runs `next dev` to run the frontend and serverless functions locally. ## Next Steps @@ -89,7 +83,6 @@ After the app is running locally, you'll want to [set up deployment](/docs/deplo - [Recommended Dev Workflow](/docs/devWorkflow.md) - [Deployment](/docs/deployment.md) -- [FAQ](/docs/faq.md) Have an idea to improve Bison? [Let us know!](https://github.com/echobind/bisonapp/issues/new) diff --git a/packages/create-bison-app/scripts/createDevAppAndStartServer.js b/packages/create-bison-app/scripts/createDevAppAndStartServer.js index 80a54315..f98be46e 100644 --- a/packages/create-bison-app/scripts/createDevAppAndStartServer.js +++ b/packages/create-bison-app/scripts/createDevAppAndStartServer.js @@ -81,7 +81,7 @@ async function init() { */ async function createTemplateSymlinks(appPath) { // Files and directories that do not exist in template that need to be linked - const relativeAppPaths = ["api.graphql", "node_modules", "types", "types.ts"]; + const relativeAppPaths = ["node_modules"]; for (const relativePath of relativeAppPaths) { await fs.promises.symlink( @@ -97,20 +97,12 @@ async function createTemplateSymlinks(appPath) { async function removeTemplateSymlinks() { const templatePath = (relativePath) => path.join(templateFolder, relativePath); - const unlinkFile = async (filename) => { - const filePath = templatePath(filename); - const lstat = fs.lstatSync(filePath, { throwIfNoEntry: false }); - if (lstat && lstat.isSymbolicLink()) { - await fs.promises.unlink(filePath); - } - }; - - await unlinkFile("api.graphql"); - await unlinkFile("types.ts"); // Directories cannot be "unlinked" so they must be removed - await fs.promises.rm(templatePath("node_modules"), { force: true, recursive: true }); - await fs.promises.rm(templatePath("types"), { force: true, recursive: true }); + await fs.promises.rm(templatePath("node_modules"), { + force: true, + recursive: true, + }); } if (require.main === module) { diff --git a/packages/create-bison-app/tasks/copyFiles.js b/packages/create-bison-app/tasks/copyFiles.js index 6f8f3acf..3cc997ff 100644 --- a/packages/create-bison-app/tasks/copyFiles.js +++ b/packages/create-bison-app/tasks/copyFiles.js @@ -62,11 +62,7 @@ async function copyFiles({ variables, targetFolder }) { copyDirectoryWithTemplate(fromPath("pages"), toPath("pages"), variables), copyDirectoryWithTemplate(fromPath("prisma"), toPath("prisma"), variables), - copyDirectoryWithTemplate( - fromPath("graphql"), - toPath("graphql"), - variables - ), + copyDirectoryWithTemplate(fromPath("server"), toPath("server"), variables), copyDirectoryWithTemplate(fromPath("tests"), toPath("tests"), variables), @@ -92,7 +88,6 @@ async function copyFiles({ variables, targetFolder }) { ".hygen.js", ".nvmrc", ".tool-versions", - "codegen.yml", "constants.ts", "cypress.config.ts", "jest.config.js", diff --git a/packages/create-bison-app/template/.vscode/extensions.json b/packages/create-bison-app/template/.vscode/extensions.json index 68fe2cd7..40760e60 100644 --- a/packages/create-bison-app/template/.vscode/extensions.json +++ b/packages/create-bison-app/template/.vscode/extensions.json @@ -6,7 +6,6 @@ "wix.vscode-import-cost", "pflannery.vscode-versionlens", "prisma.prisma", - "apollographql.vscode-apollo" ], "unwantedRecommendations": [] } diff --git a/packages/create-bison-app/template/README.md.ejs b/packages/create-bison-app/template/README.md.ejs index 71ba18e6..c5bc649a 100644 --- a/packages/create-bison-app/template/README.md.ejs +++ b/packages/create-bison-app/template/README.md.ejs @@ -6,9 +6,8 @@ # Getting Started Tutorial This checklist and mini-tutorial will make sure you make the most of your shiny new Bison app. -## Migrate your database, generate typings, and start the dev server -- [ ] Run `yarn setup:dev` to prep and migrate your local database, as well as generate the prisma client, nexus typings, and GraphQL typings. If this fails, make sure you have Postgres running and the generated `DATABASE_URL` values are correct in your `.env` files. - - For more information about code generation, view the [FAQ](https://github.com/echobind/bisonapp/blob/canary/docs/faq.md#where-can-i-find-the-generated-graphql-schema-react-hooks-and-typings). +## Migrate your database and start the dev server +- [ ] Run `yarn setup:dev` to prep and migrate your local database, as well as generate the prisma client. If this fails, make sure you have Postgres running and the generated `DATABASE_URL` values are correct in your `.env` files. - [ ] Run `yarn dev` to start your development server ## Complete a Bison workflow @@ -53,202 +52,109 @@ You should see a new folder in `prisma/migrations`. For more on Prisma, [view the docs](https://www.prisma.io/docs/). -### The GraphQL API -With the database changes complete, we need to decide what types, queries, and mutations to expose in our GraphQL API. +### The tRPC API +With the database changes complete, we need to decide what types, queries, and mutations to expose in our tRPC API. -Bison uses [Nexus Schema](https://nexusjs.org/docs/) to create the GraphQL API. Nexus provides a strongly-typed, concise way of defining GraphQL types and operations. +Bison uses [tRPC](https://trpc.io) to create an end-to-end typed API which you can easily use throughout your app. The [HTTP specification](https://trpc.io/docs/v10/rpc) is simple enough that you can easily query the API from third-party apps as well, though any apps outside of your app's monorepo lose the benefit of end-to-end type safety. -- [ ] Create a new GraphQL module using `yarn g:graphql organization` -- [ ] Edit the new module to reflect what you want to expose via the API. In the following Mutation example, we alias the Mutation name, require a user to be logged in, and force the new Organization to be owned by the logged in user. All in about 10 lines of code! +tRPC organizes its API using routers and procedures. Here's how to create a new router. -Because Nexus is strongly typed, all of the `t.` operations should autocomplete in your editor. +- [ ] Create a new Router module using `yarn g:trpc organization` +- [ ] Edit the new module to reflect what you want to expose via the API. Be as granular as you want with the query and mutation procedures. + +We'll use [zod](https://github.com/colinhacks/zod) to ensure type safety for our inputs and the `t` utility provided by tRPC to create our routers. We use the `protectedProcedure` for procedures that require users to be logged in and `adminProcedure` for procedures that require the `ADMIN` role. Unprotected procedures can be added with `t.procedure`.
- File: ./graphql/modules/organization.ts + File: ./server/routers/organization.ts ```ts - import { objectType, inputObjectType, queryField, mutationField, arg, list, nonNull } from 'nexus'; - - import { isAdmin } from '@/services/permissions'; - - // Organization Type - export const Organization = objectType({ - name: 'Organization', - description: 'A Organization', - definition(t) { - t.nonNull.id('id'); - t.nonNull.date('createdAt'); - t.nonNull.date('updatedAt'); - t.nonNull.string('name'); - - t.nonNull.list.nonNull.field('users', { - type: 'User', - resolve: async (parent, _, context) => { - return context.prisma.organization - .findUnique({ - where: { id: parent.id }, - }) - .users(); - }, - }); - }, - }); - - // Queries - export const findOrganizationsQuery = queryField('organizations', { - type: list('Organization'), - authorize: (_root, _args, ctx) => !!ctx.user, - args: { - where: arg({ type: 'OrganizationWhereInput' }), - orderBy: arg({ type: 'OrganizationOrderByInput', list: true }), - }, - description: 'Returns found organizations', - resolve: async (_root, args, ctx) => { - const { where = {}, orderBy = [] } = args; - - return await ctx.db.organization.findMany({ where, orderBy }); - } - }); - - export const findUniqueOrganizationQuery = queryField('organization', { - type: 'Organization', - description: 'Returns a specific Organization', - authorize: (_root, _args, ctx) => !!ctx.user, - args: { - where: nonNull(arg({ type: 'OrganizationWhereUniqueInput' })) - }, - resolve: (_root, args, ctx) => { - const { where } = args; - return ctx.prisma.organization.findUnique({ where }) - }, - }); + import { Prisma } from '@prisma/client'; + import { z } from 'zod'; - // Mutations - export const createOrganizationMutation = mutationField('createOrganization', { - type: 'Organization', - description: 'Creates a Organization', - authorize: (_root, _args, ctx) => isAdmin(ctx.user), - args: { - data: nonNull(arg({ type: 'CreateOrganizationInput' })), - }, - resolve: async (_root, args, ctx) => { - return await ctx.db.organization.create(args); - } - }); + import { defaultUserSelect } from './user'; - export const updateOrganizationMutation = mutationField('updateOrganization', { - type: 'Organization', - description: 'Updates a Organization', - authorize: (_root, _args, ctx) => isAdmin(ctx.user), - args: { - where: nonNull(arg({ type: 'OrganizationWhereUniqueInput'})), - data: nonNull(arg({ type: 'UpdateOrganizationInput' })), - }, - resolve: async (_root, args, ctx) => { - const { where, data } = args; - - return await ctx.db.organization.update({ where, data }); - } - }); + import { BisonError, t } from '@/server/trpc'; + import { protectedProcedure } from '@/server/middleware/auth'; - // Mutation Inputs - export const CreateOrganizationInput = inputObjectType({ - name: 'CreateOrganizationInput', - description: 'Input used to create a organization', - definition: (t) => { - t.nonNull.string('name'); - }, + // Organization default selection + export const defaultOrganizationSelect = Prisma.validator()({ + id: true, + createdAt: true, + updatedAt: true, + name: true, + users: { select: defaultUserSelect }, }); - export const UpdateOrganizationInput = inputObjectType({ - name: 'UpdateOrganizationInput', - description: 'Input used to update a organization', - definition: (t) => { - t.nonNull.string('name'); - }, - }); + export const organizationRouter = t.router({ + findMany: protectedProcedure + .input( + z.object({ + where: z.object({ name: z.string().optional() }).optional(), + orderBy: z + .object({ name: z.enum(['asc', 'desc']) }) + .array() + .optional(), + }) + ) + .query(async ({ ctx, input }) => { + const { where = {}, orderBy = [] } = input; + return await ctx.db.organization.findMany({ + where, + orderBy, + select: defaultOrganizationSelect, + }); + }), + find: protectedProcedure + .input(z.object({ where: z.object({ id: z.string() }) })) + .query(async ({ ctx, input }) => { + const { where } = input; + return ctx.prisma.organization.findUnique({ + where, + select: defaultOrganizationSelect, + }); + }), + create: protectedProcedure + .input(z.object({ data: z.object({ name: z.string() }) })) + .mutation(async ({ ctx, input }) => { + const { data } = input; + return await ctx.db.organization.create({ + data: { ...data, users: { connect: [{ id: ctx.user.id }] } }, + select: defaultOrganizationSelect, + }); + }), + update: protectedProcedure + .input( + z.object({ + where: z.object({ id: z.string() }), + data: z.object({ name: z.string() }), + }) + ) + .mutation(async ({ ctx, input }) => { + const { where, data } = input; + + const organization = await ctx.db.organization.findFirst({ + where: { id: where.id, users: { some: { id: ctx.user.id } } }, + }); + + if (!organization) { + throw new BisonError({ + code: 'FORBIDDEN', + message: 'You are not allowed to edit this organization.', + }); + } - // Query Inputs - export const OrganizationOrderByInput = inputObjectType({ - name: 'OrganizationOrderByInput', - description: 'Order organization by a specific field', - definition(t) { - t.field('name', { type: 'SortOrder' }); - }, + return await ctx.db.organization.update({ where, data, select: defaultOrganizationSelect }); + }), }); - export const OrganizationWhereUniqueInput = inputObjectType({ - name: 'OrganizationWhereUniqueInput', - description: 'Input to find organizations based on unique fields', - definition(t) { - t.id('id'); - // add DB uniq fields here - // t.string('name'); - }, - }); - export const OrganizationWhereInput = inputObjectType({ - name: 'OrganizationWhereInput', - description: 'Input to find organizations based on other fields', - definition(t) { - t.field('name', { type: 'StringFilter' }); - }, - }); ```
-### Understanding the GraphQL API and TypeScript types -- [ ] Open `api.graphql` and look at our the new definitions that were generated for you: +> **Where are my types?** +> +> tRPC doesn't require type code generation - all of the types are inferred using the router and procedure definitions. This means you don't have to run a separate process to watch for changes to your schema to generate types. -```graphql - -""" -A Organization -""" -type Organization { - createdAt: DateTime! - id: ID! - name: String! - updatedAt: DateTime! -} - -""" -Order organization by a specific field -""" -input OrganizationOrderByInput { - name: SortOrder -} - -""" -Input to find organizations based on other fields -""" -input OrganizationWhereInput { - name: StringFilter -} - -""" -Input to find organizations based on unique fields -""" -input OrganizationWhereUniqueInput { - id: ID -} - -""" -Input used to create a organization -""" -input CreateOrganizationInput { - name: String! -} - -""" -Input used to update a organization -""" -input UpdateOrganizationInput { - name: String! -} -``` - -- [ ] Open up `types.ts` to see the generated TypeScript types that correspond with the GraphQL changes. ### API Request Tests Let's confirm the API changes using a request test. To do this: @@ -267,67 +173,43 @@ export const OrganizationFactory = { ``` - [ ] Generate a new API request test: `yarn g:test:request createOrganization` -- [ ] Update the API request test to call the new mutation and ensure that we get an error if not logged in. If you are curious what the `Input` type should be, check `api.graphql`. +- [ ] Update the API request test to call the new mutation and ensure that we get an error if not logged in. TypeScript can help you make sure you get the right inputs for the procedures. Here we use inline snapshots to confirm the error message content, but you can also manually assert the content. ```ts -import { graphQLRequest, graphQLRequestAsUser, resetDB, disconnect } from '@/tests/helpers'; -import { OrganizationFactory } from '@/tests/factories/organization'; +import { UserFactory } from '../factories'; + +import { trpcRequest, resetDB, disconnect } from '@/tests/helpers'; beforeEach(async () => resetDB()); afterAll(async () => disconnect()); -describe('createOrganization mutation', () => { +describe('create mutation', () => { it('returns an error if not logged in', async () => { - const query = ` - mutation createOrganization($data: OrganizationCreateInput!) { - createOrganization(data: $data) { - id - name - users { - email - } - } - } - `; - const variables = { data: { name: 'Cool Company' } }; - const response = await graphQLRequest({ query, variables }); - const errorMessages = response.body.errors.map((e) => e.message); - - expect(errorMessages).toMatchInlineSnapshot(` - Array [ - "Not authorized", - ] - `); + + await expect( + trpcRequest().organization.create(variables) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"UNAUTHORIZED"`); }); }); + ``` - [ ] Add a new test to confirm that the organization user is set to the current user ```ts -it('sets the user to the logged in user', async () => { - const query = ` - mutation createOrganization($data: OrganizationCreateInput!) { - createOrganization(data: $data) { - id - name - users { - id - } - } - } -`; +describe('as a user', () => { + it('sets the user to the logged in user', async () => { + const user = await UserFactory.create(); + const variables = { data: { name: 'Cool Company' } }; - const user = await UserFactory.create(); - const variables = { data: { name: 'Cool Company', users: { connect: [{ id: 'notmyid' }] } } }; - const response = await graphQLRequestAsUser(user, { query, variables }); - const organization = response.body.data.createOrganization; - const [organizationUser] = organization.users; + const { name, users } = await trpcRequest(user).organization.create(variables); - expect(organizationUser.id).toEqual(user.id); + expect(name).toEqual('Cool Company'); + expect(users[0].id).toEqual(user.id); + }); }); ``` @@ -337,19 +219,26 @@ Now that we have the API finished, we can move to the frontend changes. - [ ] Create a new page to create organizations: `yarn g:page organizations/new` - [ ] Create an `OrganizationForm` component: `yarn g:component OrganizationForm` - [ ] Add a simple form with a name input. See the [React Hook Form docs](https://react-hook-form.com) for detailed information. +- [ ] Use tRPC to call the create organization mutation. ```tsx import { useForm } from 'react-hook-form'; +interface OrganizationFormData { + name: string; +} + export function OrganizationForm() { const { register, handleSubmit, formState: { errors }, - } = useForm(); + } = useForm(); - async function onSubmit(data) { - console.log(data); + const createMutation = trpc.organization.create.useMutation(); + + async function onSubmit(data: OrganizationFormData) { + createMutation.mutate(data); } return ( @@ -366,11 +255,19 @@ export function OrganizationForm() { - [ ] Update the form to use Chakra components ```tsx +import { Button, FormControl, FormLabel, Input } from '@chakra-ui/react'; +import { useForm } from 'react-hook-form'; + +import { ErrorText } from './ErrorText'; + +export function OrganizationForm() { + // ... + return (
Name - + {errors.name && errors.name.message} @@ -380,61 +277,13 @@ export function OrganizationForm() {
); } -``` - -- [ ] Add a GraphQL mutation to create an organization (use the same code from the API request test to keep it easy!) -- [ ] Make sure you import `gql` from `@apollo/client` since we are working in the frontend. - -```tsx -export const CREATE_ORGANIZATION_MUTATION = gql` - mutation createOrganization($data: OrganizationCreateInput!) { - createOrganization(data: $data) { - id - name - users { - id - } - } - } -`; -``` - -- [ ] Save the file. You should see GraphQL Codegen pickup on the changes. -- [ ] Open `types.ts`. Codegen should have created a new hook called `useCreateOrganizationMutation`, which we can use to get fully typed GraphQL operations! - -```tsx -// types.ts - search for the following function: - -export function useCreateOrganizationMutation( - baseOptions?: Apollo.MutationHookOptions< - CreateOrganizationMutation, - CreateOrganizationMutationVariables - > -) { -``` - -- [ ] Use the newly generated hook to save the results of the form: - -```tsx -export function OrganizationForm() { - const { - register, - handleSubmit, - formState: { errors }, - } = useForm(); - - const [createOrganization, { data, loading, error }] = useCreateOrganizationMutation(); - async function onSubmit(data) { - createOrganization(data); - } -} ``` - [ ] Attach the mutations loading state to the button loading state ```tsx - ``` @@ -445,101 +294,33 @@ You should now have a fully working form that creates a new database entry on su - [ ] Generate a new page: `yarn g:page "organizations/[:id]"`. This uses the dynamic page capability of Next.js. - [ ] Add a new "cell" to fetch data. While not required, it keeps things clean. `yarn g:cell Organization` -- [ ] Add a query to the cell that fetches organization data - -```jsx -import gql from 'graphql-tag'; -import { Spinner, Text } from '@chakra-ui/react'; - -export const QUERY = gql` - query organization { - organization { - name - } - } -`; - -export const Loading = () => ; -export const Error = () => Error. See dev tools.; -export const Empty = () => No data.; - -export const Success = () => { - return Awesome!; -}; - -export const OrganizationCell = () => { - return ; -}; -``` - -- [ ] Verify we get an error about querying for organizations in the dev server console. - -``` -[WATCHERS] [GQLCODEGEN] GraphQLDocumentError: Field "organization" argument "where" of type "OrganizationWhereUniqueInput!" is required, but it was not provided. -``` - -We forgot to add a where clause to our organization query that's in the cell. Let's do that now. - -- [ ] Open `api.graphql` to see the parameters we can pass to the organization query. -- [ ] Copy the `where` parameter and use it in our cell. - -```graphql -type Query { - ''' - organization(where: OrganizationWhereUniqueInput!): Organization - ''' -} -``` - -- [ ] Update the organization query to take a parameter and use the where query +- [ ] Add a prop to the cell for `organizationId` and pass the value to a tRPC `useQuery` call. +- [ ] Update the `Success` component to take the proper return type of the query, but make it NonNullable. +- [ ] Only render the `Success` component if `data` is present. ```tsx -export const QUERY = gql` - query organization($where: OrganizationWhereUniqueInput!) { - organization(where: $where) { - name - } - } -`; -``` - -- [ ] Use the newly generated hook from `types.ts` to fetch data in the cell. -- [ ] Add a prop to the cell for `organizationId` and pass the value to the query. -- [ ] Udate the `Success` component to take the proper return type for the query -- [ ] Only render the `Success` component if `data.organization` is present. - -```tsx -import gql from 'graphql-tag'; import { Spinner, Text } from '@chakra-ui/react'; -import { OrganizationQuery, useOrganizationQuery } from '@/types'; - -export const QUERY = gql` - query organization($where: OrganizationWhereUniqueInput!) { - organization(where: $where) { - name - } - } -`; +import { trpc, inferQueryOutput } from '@/lib/trpc'; export const Loading = () => ; export const Error = () => Error. See dev tools.; export const Empty = () => No data.; -export const Success = ({ organization }: OrganizationQuery) => { +export const Success = ( + organization: NonNullable> +) => { return Awesome! {organization.name}; }; -export const OrganizationCell = ({ organizationId }) => { - const { data, loading, error } = useOrganizationQuery({ - variables: { - where: { id: organizationId }, - }, +export const OrganizationCell = ({ organizationId }: { organizationId: string }) => { + const { data, isLoading, isError } = trpc.organization.find.useQuery({ + where: { id: organizationId }, }); - if (loading) return ; - if (error) return ; - if (data.organization) return ; + if (isLoading) return ; + if (isError) return ; + if (data) return ; return ; }; diff --git a/packages/create-bison-app/template/_.gitignore b/packages/create-bison-app/template/_.gitignore index 7687b6e1..46543342 100644 --- a/packages/create-bison-app/template/_.gitignore +++ b/packages/create-bison-app/template/_.gitignore @@ -12,9 +12,6 @@ .next/ out/ -# nexus -.nexus - # typescript tsconfig.tsbuildinfo @@ -44,9 +41,4 @@ cypress/screenshots* cypress/logs* !cypress/videos/.gitkeep !cypress/screenshots/.gitkeep -!cypress/logs/.gitkeep - -# generated files -/api.graphql -/types.ts -/types/nexus.d.ts \ No newline at end of file +!cypress/logs/.gitkeep \ No newline at end of file diff --git a/packages/create-bison-app/template/_templates/cell/new/new.ejs b/packages/create-bison-app/template/_templates/cell/new/new.ejs index 0141fba6..ee0e81f8 100644 --- a/packages/create-bison-app/template/_templates/cell/new/new.ejs +++ b/packages/create-bison-app/template/_templates/cell/new/new.ejs @@ -3,33 +3,24 @@ to: cells/<%= [h.inflection.camelize(h.dirName(name)), h.camelizedBaseName(name) --- <% formattedPath = h.camelizedPathName(name) -%> <% component = h.camelizedBaseName(name) -%> -import gql from 'graphql-tag'; import { Spinner, Text } from '@chakra-ui/react'; -import { useMyProfileQuery, MyProfileQuery } from '@/types'; - -export const QUERY = gql` - query myProfile { - me { - email - } - } -`; +import { trpc, inferQueryOutput } from '@/lib/trpc'; export const Loading = () => ; export const Error = () => Error. See dev tools.; export const Empty = () => No data.; -export const Success = ({ me }: MyProfileQuery) => { - return Awesome! {me.email}; +export const Success = (props: inferQueryOutput<'user', 'me'>) => { + return Awesome! {props.email}; }; export const <%= component %>Cell = () => { - const { data, loading, error } = useMyProfileQuery(); + const { data, isLoading, isError } = trpc.user.me.useQuery(); - if (loading) return ; - if (error) return ; - if (data.me) return ; + if (isLoading) return ; + if (isError) return ; + if (data) return ; return ; }; diff --git a/packages/create-bison-app/template/_templates/graphql/new/graphql.ejs b/packages/create-bison-app/template/_templates/graphql/new/graphql.ejs deleted file mode 100644 index 9a611612..00000000 --- a/packages/create-bison-app/template/_templates/graphql/new/graphql.ejs +++ /dev/null @@ -1,121 +0,0 @@ ---- -to: graphql/modules/<%= name %>.ts ---- -<% camelized = h.inflection.camelize(name) -%> -<% plural = h.inflection.pluralize(camelized) -%> -import { objectType, inputObjectType, queryField, mutationField, arg, list, nonNull } from 'nexus'; - -import { isAdmin } from '@/services/permissions'; - -// <%= camelized %> Type -export const <%= camelized %> = objectType({ - name: '<%= camelized %>', - description: 'A <%= camelized %>', - definition(t) { - t.nonNull.id('id'); - t.nonNull.date('createdAt'); - t.nonNull.date('updatedAt'); - t.nonNull.string('name'); - }, -}); - -// Queries -export const find<%= plural %>Query = queryField('<%= plural.toLowerCase() %>', { - type: list('<%= camelized %>'), - authorize: (_root, _args, ctx) => !!ctx.user, - args: { - where: arg({ type: '<%= camelized %>WhereInput' }), - orderBy: arg({ type: '<%= camelized %>OrderByInput', list: true }), - }, - description: 'Returns found <%= plural.toLowerCase() %>', - resolve: async (_root, args, ctx) => { - const { where = {}, orderBy = [] } = args; - - return await ctx.db.<%= name %>.findMany({ where, orderBy }); - } -}); - -export const findUnique<%= camelized %>Query = queryField('<%= name.toLowerCase() %>', { - type: '<%= camelized %>', - description: 'Returns a specific <%= camelized %>', - authorize: (_root, _args, ctx) => !!ctx.user, - args: { - where: nonNull(arg({ type: '<%= camelized%>WhereUniqueInput' })) - }, - resolve: (_root, args, ctx) => { - const { where } = args; - return ctx.prisma.<%= name %>.findUnique({ where }) - }, -}); - -// Mutations -export const create<%= camelized %>Mutation = mutationField('create<%= camelized %>', { - type: '<%= camelized %>', - description: 'Creates a <%= camelized %>', - authorize: (_root, _args, ctx) => isAdmin(ctx.user), - args: { - data: nonNull(arg({ type: 'Create<%= camelized %>Input' })), - }, - resolve: async (_root, args, ctx) => { - return await ctx.db.<%= name %>.create(args); - } -}); - -export const update<%= camelized %>Mutation = mutationField('update<%= camelized %>', { - type: '<%= camelized %>', - description: 'Updates a <%= camelized %>', - authorize: (_root, _args, ctx) => isAdmin(ctx.user), - args: { - where: nonNull(arg({ type: '<%= camelized %>WhereUniqueInput'})), - data: nonNull(arg({ type: 'Update<%= camelized %>Input' })), - }, - resolve: async (_root, args, ctx) => { - const { where, data } = args; - - return await ctx.db.<%= name %>.update({ where, data }); - } -}); - -// MUTATION INPUTS -export const Create<%= camelized %>Input = inputObjectType({ - name: 'Create<%= camelized %>Input', - description: 'Input used to create a <%= name %>', - definition: (t) => { - t.nonNull.string('name'); - }, -}); - -export const Update<%= camelized %>Input = inputObjectType({ - name: 'Update<%= camelized %>Input', - description: 'Input used to update a <%= name %>', - definition: (t) => { - t.nonNull.string('name'); - }, -}); - -// QUERY INPUTS -export const <%= camelized %>OrderByInput = inputObjectType({ - name: '<%= camelized %>OrderByInput', - description: 'Order <%= camelized.toLowerCase() %> by a specific field', - definition(t) { - t.field('name', { type: 'SortOrder' }); - }, -}); - -export const <%= camelized %>WhereUniqueInput = inputObjectType({ - name: '<%= camelized %>WhereUniqueInput', - description: 'Input to find <%= plural.toLowerCase() %> based on unique fields', - definition(t) { - t.id('id'); - // add DB uniq fields here - // t.string('name'); - }, -}); - -export const <%= camelized %>WhereInput = inputObjectType({ - name: '<%= camelized %>WhereInput', - description: 'Input to find <%= plural.toLowerCase() %> based on other fields', - definition(t) { - t.field('name', { type: 'StringFilter' }); - }, -}); diff --git a/packages/create-bison-app/template/_templates/graphql/new/injectImport.ejs b/packages/create-bison-app/template/_templates/graphql/new/injectImport.ejs deleted file mode 100644 index 7d178759..00000000 --- a/packages/create-bison-app/template/_templates/graphql/new/injectImport.ejs +++ /dev/null @@ -1,7 +0,0 @@ ---- -inject: true -to: graphql/modules/index.ts -after: export * from './scalars' -skip_if: export * from './<%= name %>' ---- -export * from './<%= name %>'; \ No newline at end of file diff --git a/packages/create-bison-app/template/_templates/test/factory/factory.ejs b/packages/create-bison-app/template/_templates/test/factory/factory.ejs index f76090d8..196f671b 100644 --- a/packages/create-bison-app/template/_templates/test/factory/factory.ejs +++ b/packages/create-bison-app/template/_templates/test/factory/factory.ejs @@ -7,7 +7,7 @@ import { Prisma } from '@prisma/client'; // import Chance from 'chance'; import { buildPrismaIncludeFromAttrs } from '@/tests/helpers/buildPrismaIncludeFromAttrs'; -import { prisma } from '@/lib/prisma' +import { prisma } from '@/lib/prisma'; // const chance = new Chance(); @@ -26,7 +26,7 @@ export const <%= camelized %>Factory = { return await prisma.<%= single %>.create({ data: { ...<%= single %> }, - ...options + ...options, }); }, }; diff --git a/packages/create-bison-app/template/_templates/test/request/request.ejs b/packages/create-bison-app/template/_templates/test/request/request.ejs index 7cde2ca3..7d7ee81e 100644 --- a/packages/create-bison-app/template/_templates/test/request/request.ejs +++ b/packages/create-bison-app/template/_templates/test/request/request.ejs @@ -5,52 +5,34 @@ to: tests/requests/<%= name %>.test.ts <% model = name.split('/')[0] %> <% section = h.baseName(name) -%> <% upper = h.inflection.camelize(model, false) -%> -import { graphQLRequest, graphQLRequestAsUser, resetDB, disconnect } from '@/tests/helpers'; +import { trpcRequest, resetDB, disconnect } from '@/tests/helpers'; import { <%= upper %>Factory } from '@/tests/factories/<%= model %>'; beforeEach(async () => resetDB()); afterAll(async () => disconnect()); describe('<%= upper %> <%= section %> mutation', () => { - const query = ` - mutation LOGIN($email: String!, $password: String!) { - login(email: $email, password: $password) { - user { - email - } - } - } - `; - describe('invalid email', () => { it('returns an Authentication error', async () => { await <%= upper %>Factory.create({ email: 'foo@wee.net' }); const variables = { email: 'fake', password: 'fake' }; - const response = await graphQLRequest({ query, variables }); - const errorMessages = response.body.errors.map((e) => e.message); - expect(errorMessages).toMatchInlineSnapshot(); + await expect( + trpcRequest().user.login(variables) + ).rejects.toThrowErrorMatchingInlineSnapshot(); }); }); describe('as a user', () => { it('does something expected', async () => { - const query = ` - query ME { - me { - user { - email - } - } - } - `; const user = await UserFactory.create(); const <%= model %> = await <%= upper %>Factory.create(); - const response = await graphQLRequestAsUser(user, { query }); - expect(response.body).toBe(true) + const { email, roles } = await trpcRequest(user).user.me(); + + expect({ email, roles }).toEqual({ email: user.email, roles: user.roles }); }) }) }); diff --git a/packages/create-bison-app/template/_templates/trpc/new/injectExport.ejs b/packages/create-bison-app/template/_templates/trpc/new/injectExport.ejs new file mode 100644 index 00000000..bf997e60 --- /dev/null +++ b/packages/create-bison-app/template/_templates/trpc/new/injectExport.ejs @@ -0,0 +1,7 @@ +--- +inject: true +to: server/routers/_app.ts +before: \}\);\n\nexport type AppRouter +skip_if: "<%= name %>: <%= name %>Router," +--- + <%= name %>: <%= name %>Router, \ No newline at end of file diff --git a/packages/create-bison-app/template/_templates/trpc/new/injectImport.ejs b/packages/create-bison-app/template/_templates/trpc/new/injectImport.ejs new file mode 100644 index 00000000..59182af0 --- /dev/null +++ b/packages/create-bison-app/template/_templates/trpc/new/injectImport.ejs @@ -0,0 +1,7 @@ +--- +inject: true +to: server/routers/_app.ts +before: \nimport \{ t \} from '@/server/trpc'; +skip_if: import { <%= h.inflection.camelize(name).toLowerCase() %>Router } from './<%= h.inflection.camelize(name).toLowerCase() %>'; +--- +import { <%= h.inflection.camelize(name).toLowerCase() %>Router } from './<%= h.inflection.camelize(name).toLowerCase() %>'; \ No newline at end of file diff --git a/packages/create-bison-app/template/_templates/trpc/new/trpc.ejs b/packages/create-bison-app/template/_templates/trpc/new/trpc.ejs new file mode 100644 index 00000000..cc456f6a --- /dev/null +++ b/packages/create-bison-app/template/_templates/trpc/new/trpc.ejs @@ -0,0 +1,63 @@ +--- +to: server/routers/<%= name %>.ts +--- +<% camelized = h.inflection.camelize(name) -%> +<% plural = h.inflection.pluralize(camelized) -%> +import { Prisma } from '@prisma/client'; +import { z } from 'zod'; + +import { t } from '@/server/trpc'; +import { adminProcedure, protectedProcedure } from '@/server/middleware/auth'; + +// <%= camelized %> default selection +export const default<%= camelized %>Select = Prisma.validator Select>()({ + id: true, + createdAt: true, + updatedAt: true, + name: true, +}); + +export const <%= camelized.toLowerCase() %>Router = t.router({ + findMany: protectedProcedure + .input( + z.object({ + where: z.object({ name: z.string().optional() }).optional(), + orderBy: z + .object({ name: z.enum(['asc', 'desc']) }) + .array() + .optional(), + }) + ) + .query(async ({ ctx, input }) => { + const { where = {}, orderBy = [] } = input; + return await ctx.db.<%= name %>.findMany({ + where, + orderBy, + select: default<%= camelized %>Select, + }); + }), + find: protectedProcedure + .input(z.object({ where: z.object({ id: z.string() }) })) + .query(async ({ ctx, input }) => { + const { where } = input; + return ctx.prisma.<%= name %>.findUnique({ where, select: default<%= camelized %>Select }); + }), + create: adminProcedure + .input(z.object({ data: z.object({ name: z.string() }) })) + .mutation(async ({ ctx, input }) => { + const { data } = input; + return await ctx.db.<%= name %>.create({ data, select: default<%= camelized %>Select }); + }), + update: adminProcedure + .input( + z.object({ + where: z.object({ id: z.string() }), + data: z.object({ name: z.string() }), + }) + ) + .mutation(async ({ ctx, input }) => { + const { where, data } = input; + + return await ctx.db.<%= name %>.update({ where, data, select: default<%= camelized %>Select }); + }), +}); diff --git a/packages/create-bison-app/template/codegen.yml b/packages/create-bison-app/template/codegen.yml deleted file mode 100644 index 7b1811b9..00000000 --- a/packages/create-bison-app/template/codegen.yml +++ /dev/null @@ -1,26 +0,0 @@ -schema: - - api.graphql -overwrite: true -generates: - types.ts: - documents: - - 'pages/**/*.{ts,tsx}' - - 'components/**/*.{ts,tsx}' - - 'cells/**/*.{ts,tsx}' - - 'layouts/**/*.{ts,tsx}' - - 'context/**/*.{ts,tsx}' - - '!pages/api*' - plugins: - - add: - content: '/* eslint-disable */' - - typescript - - typescript-operations - - typescript-react-apollo - config: - withComponent: false - withHOC: false - withHooks: true - reactApolloVersion: 3 - namingConvention: - typeNames: change-case#pascalCase - enumValues: change-case#upperCase diff --git a/packages/create-bison-app/template/components/AllProviders.tsx b/packages/create-bison-app/template/components/AllProviders.tsx index c945dcfa..8742f6cd 100644 --- a/packages/create-bison-app/template/components/AllProviders.tsx +++ b/packages/create-bison-app/template/components/AllProviders.tsx @@ -1,37 +1,26 @@ import { ReactNode } from 'react'; -import { ApolloClient, ApolloProvider, NormalizedCacheObject } from '@apollo/client'; import { ChakraProvider, CSSReset } from '@chakra-ui/react'; import { Dict } from '@chakra-ui/utils'; import { AuthProvider } from '@/context/auth'; -import { createApolloClient } from '@/lib/apolloClient'; import defaultTheme from '@/chakra'; interface Props { - apolloClient?: ApolloClient; children: ReactNode; theme?: Dict; } -const defaultApolloClient = createApolloClient(); - /** * Renders all context providers */ -export function AllProviders({ - apolloClient = defaultApolloClient, - theme = defaultTheme, - children, -}: Props) { +export function AllProviders({ theme = defaultTheme, children }: Props) { return ( - - - - + + + - {children} - - - + {children} + + ); } diff --git a/packages/create-bison-app/template/components/LoginForm.tsx b/packages/create-bison-app/template/components/LoginForm.tsx index 2b66daba..1756e446 100644 --- a/packages/create-bison-app/template/components/LoginForm.tsx +++ b/packages/create-bison-app/template/components/LoginForm.tsx @@ -1,23 +1,17 @@ -import { useState } from 'react'; import { useRouter } from 'next/router'; import { Flex, Text, FormControl, FormLabel, Input, Stack, Button, Circle } from '@chakra-ui/react'; import { useForm } from 'react-hook-form'; -import { gql } from '@apollo/client'; +import { inferProcedureInput } from '@trpc/server'; import { EMAIL_REGEX } from '@/constants'; import { useAuth } from '@/context/auth'; import { ErrorText } from '@/components/ErrorText'; import { Link } from '@/components/Link'; -import { setErrorsFromGraphQLErrors } from '@/utils/setErrors'; -import { LoginMutationVariables, useLoginMutation } from '@/types'; +import { setErrorsFromTRPCError } from '@/utils/setErrors'; +import { AppRouter } from '@/server/routers/_app'; +import { trpc } from '@/lib/trpc'; -export const LOGIN_MUTATION = gql` - mutation login($email: String!, $password: String!) { - login(email: $email, password: $password) { - token - } - } -`; +type LoginInput = inferProcedureInput; /** Form to Login */ export function LoginForm() { @@ -26,10 +20,10 @@ export function LoginForm() { handleSubmit, formState: { errors }, setError, - } = useForm(); + } = useForm(); + + const loginMutation = trpc.user.login.useMutation(); - const [isLoading, setIsLoading] = useState(false); - const [login] = useLoginMutation(); const { login: loginUser } = useAuth(); const router = useRouter(); @@ -37,20 +31,18 @@ export function LoginForm() { * Submits the login form * @param formData the data passed from the form hook */ - async function handleLogin(formData: LoginMutationVariables) { + async function handleLogin(formData: LoginInput) { try { - setIsLoading(true); - const { data } = await login({ variables: formData }); + const loginData = await loginMutation.mutateAsync(formData); - if (!data?.login?.token) { + if (!loginData?.token) { throw new Error('Login failed.'); } - await loginUser(data.login.token); + await loginUser(loginData.token); await router.replace('/'); } catch (e: any) { - setErrorsFromGraphQLErrors(setError, e.graphQLErrors); - setIsLoading(false); + setErrorsFromTRPCError(setError, e); } } @@ -96,7 +88,7 @@ export function LoginForm() { type="submit" marginTop={8} width="full" - isLoading={isLoading} + isLoading={loginMutation.isLoading} onClick={handleSubmit(handleLogin)} > Login diff --git a/packages/create-bison-app/template/components/SignupForm.tsx b/packages/create-bison-app/template/components/SignupForm.tsx index 955dc8ca..9138790b 100644 --- a/packages/create-bison-app/template/components/SignupForm.tsx +++ b/packages/create-bison-app/template/components/SignupForm.tsx @@ -1,29 +1,19 @@ import { useState } from 'react'; import { Flex, Text, FormControl, FormLabel, Input, Stack, Button, Circle } from '@chakra-ui/react'; -import { gql } from '@apollo/client'; import { useForm } from 'react-hook-form'; import { useRouter } from 'next/router'; +import { inferProcedureInput } from '@trpc/server'; import { useAuth } from '@/context/auth'; -import { setErrorsFromGraphQLErrors } from '@/utils/setErrors'; -import { SignupMutationVariables, useSignupMutation } from '@/types'; +import { setErrorsFromTRPCError } from '@/utils/setErrors'; import { EMAIL_REGEX } from '@/constants'; import { Link } from '@/components/Link'; import { ErrorText } from '@/components/ErrorText'; +import { AppRouter } from '@/server/routers/_app'; +import { trpc } from '@/lib/trpc'; -export const SIGNUP_MUTATION = gql` - mutation signup($data: SignupInput!) { - signup(data: $data) { - token - user { - id - } - } - } -`; - -type SignupFormValue = Pick & - Pick; +type SignupInput = inferProcedureInput; +type SignupFormValue = Omit & Pick['profile']; /** Form to sign up */ export function SignupForm() { @@ -32,10 +22,11 @@ export function SignupForm() { handleSubmit, formState: { errors }, setError, - } = useForm(); + } = useForm({ mode: 'onChange' }); const [isLoading, setIsLoading] = useState(false); - const [signup] = useSignupMutation(); + const signupMutation = trpc.user.signup.useMutation(); + const { login } = useAuth(); const router = useRouter(); @@ -48,21 +39,23 @@ export function SignupForm() { setIsLoading(true); const { email, password, ...profile } = formData; - const variables: SignupMutationVariables = { - data: { email, password, profile: { create: profile } }, + const input: SignupInput = { + email, + password, + profile, }; - const { data } = await signup({ variables }); + const data = await signupMutation.mutateAsync(input); - if (!data?.signup?.token) { + if (!data?.token) { throw new Error('Signup failed.'); } - await login(data.signup.token); + await login(data.token); router.replace('/'); } catch (e: any) { - setErrorsFromGraphQLErrors(setError, e.graphQLErrors); + setErrorsFromTRPCError(setError, e); } finally { setIsLoading(false); } @@ -99,7 +92,10 @@ export function SignupForm() { Password {errors.password && errors.password.message} diff --git a/packages/create-bison-app/template/context/auth.tsx b/packages/create-bison-app/template/context/auth.tsx index d461d9a3..9f46c659 100644 --- a/packages/create-bison-app/template/context/auth.tsx +++ b/packages/create-bison-app/template/context/auth.tsx @@ -1,10 +1,10 @@ -import { createContext, useContext, ReactNode, useState, useEffect } from 'react'; -import { gql } from '@apollo/client'; +import { createContext, useContext, ReactNode, useMemo, useState, useEffect } from 'react'; +import { User } from '@prisma/client'; import { cookies } from '@/lib/cookies'; -import { useMeLazyQuery, User } from '@/types'; import { FullPageSpinner } from '@/components/FullPageSpinner'; import { LOGIN_TOKEN_KEY } from '@/constants'; +import { trpc } from '@/lib/trpc'; const oneYearMs = 365 * 24 * 60 * 60 * 1000; // how long a login session lasts in milliseconds @@ -17,63 +17,50 @@ const AuthContext = createContext({ AuthContext.displayName = 'AuthContext'; -export const ME_QUERY = gql` - query me { - me { - id - email - } - } -`; - function AuthProvider({ ...props }: Props) { - const [tokenLoaded, setTokenLoaded] = useState(true); - const [loadCurrentUser, { called, data, loading, refetch }] = useMeLazyQuery(); - const user = data?.me; + const [token, setToken] = useState(null); - // Load current user if there's a token useEffect(() => { - if (called) return; + // We set the token in useEffect to make sure the page properly + // hydrates, since the server doesn't have access to the cookie + // at this point in the rendering process. + setToken(cookies().get(LOGIN_TOKEN_KEY)); + }, []); + + const meQuery = trpc.user.me.useQuery(undefined, { enabled: !!token }); + + const user = meQuery.data || null; + + const value = useMemo(() => { + /** + * Logs in a user by setting an auth token in a cookie. We use cookies so they are available in SSR. + * @param token the token to login with + */ + function login(token: string) { + cookies().set(LOGIN_TOKEN_KEY, token, { + path: '/', + expires: new Date(Date.now() + sessionLifetimeMs), + }); + + return meQuery.refetch(); + } - async function fetchUser() { - const token = cookies().get(LOGIN_TOKEN_KEY); - setTokenLoaded(true); - if (token) await loadCurrentUser(); + /** + * Logs out a user by removing their token from cookies. + */ + async function logout() { + cookies().remove(LOGIN_TOKEN_KEY, { path: '/' }); + setToken(null); + meQuery.remove(); } - fetchUser(); - }, [loadCurrentUser, called]); + return { user, login, logout }; + }, [meQuery, user]); - if (!tokenLoaded || (tokenLoaded && loading)) { + if (!!token && meQuery.isInitialLoading) { return ; } - /** - * Logs in a user by setting an auth token in a cookie. We use cookies so they are available in SSR. - * @param token the token to login with - */ - function login(token: string) { - cookies().set(LOGIN_TOKEN_KEY, token, { - path: '/', - expires: new Date(Date.now() + sessionLifetimeMs), - }); - - const fetchUserData = called ? refetch : loadCurrentUser; - return fetchUserData(); - } - - /** - * Logs out a user by removing their token from cookies. - */ - async function logout() { - cookies().remove(LOGIN_TOKEN_KEY, { path: '/' }); - - // TODO: remove from cache rather than call API - const fetchUserData = called ? refetch : loadCurrentUser; - return fetchUserData(); - } - - const value = { user, login, logout }; return ; } diff --git a/packages/create-bison-app/template/cypress/plugins/index.ts b/packages/create-bison-app/template/cypress/plugins/index.ts index ef2a4e6b..a55afbf7 100644 --- a/packages/create-bison-app/template/cypress/plugins/index.ts +++ b/packages/create-bison-app/template/cypress/plugins/index.ts @@ -4,7 +4,7 @@ import { User } from '@prisma/client'; import { cookies } from '@/lib/cookies'; import { LOGIN_TOKEN_KEY } from '@/constants'; -import { resetDB, disconnect, setupDB, graphQLRequest } from '@/tests/helpers'; +import { resetDB, disconnect, setupDB, trpcRequest } from '@/tests/helpers'; import * as Factories from '@/tests/factories'; export interface LoginTaskObject { @@ -43,7 +43,7 @@ export const setupNodeEvents: Cypress.ConfigOptions['setupNodeEvents'] = (on, _c disconnectDB: () => { return disconnect(); }, - factory: ({ name, attrs }: { name: FactoryNames, attrs: any }) => { + factory: ({ name, attrs }: { name: FactoryNames; attrs: any }) => { const Factory = Factories[`${name}Factory`]; return Factory.create(attrs); @@ -52,16 +52,8 @@ export const setupNodeEvents: Cypress.ConfigOptions['setupNodeEvents'] = (on, _c const { email, password } = attrs; const variables = { email, password }; - const query = ` - mutation login($email: String!, $password: String!) { - login(email: $email, password: $password) { - token - } - } - `; - - const response = await graphQLRequest({ query, variables }); - const { token } = response.body.data.login; + const response = await trpcRequest().user.login(variables); + const { token } = response; cookies().set(LOGIN_TOKEN_KEY, token); return { token }; }, diff --git a/packages/create-bison-app/template/graphql/context.ts b/packages/create-bison-app/template/graphql/context.ts deleted file mode 100644 index 000ffe2e..00000000 --- a/packages/create-bison-app/template/graphql/context.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { IncomingMessage } from 'http'; - -import { Context as ApolloContext } from 'apollo-server-core'; -import { PrismaClient, User } from '@prisma/client'; - -import { prisma } from '@/lib/prisma'; -import { verifyAuthHeader } from '@/services/auth'; - -/** - * Populates a context object for use in resolvers. - * If there is a valid auth token in the authorization header, it will add the user to the context - * @param context context from apollo server - */ -export async function createContext(context: ApolloApiContext): Promise { - const authHeader = verifyAuthHeader(context.req.headers.authorization); - let user: User | null = null; - - if (authHeader) { - user = await prisma.user.findUnique({ where: { id: authHeader.userId } }); - } - - return { - db: prisma, - prisma, - user, - }; -} - -type ApolloApiContext = ApolloContext<{ req: IncomingMessage }>; - -export type Context = { - db: PrismaClient; - prisma: PrismaClient; - user: User | null; -}; diff --git a/packages/create-bison-app/template/graphql/errors.ts b/packages/create-bison-app/template/graphql/errors.ts deleted file mode 100644 index 37f3a88f..00000000 --- a/packages/create-bison-app/template/graphql/errors.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { ApolloError } from 'apollo-server-errors'; - -export class NotFoundError extends ApolloError { - constructor(message: string) { - super(message, 'NOT_FOUND'); - - Object.defineProperty(this, 'name', { value: 'NotFoundError' }); - } -} diff --git a/packages/create-bison-app/template/graphql/helpers.ts b/packages/create-bison-app/template/graphql/helpers.ts deleted file mode 100644 index 2f33846c..00000000 --- a/packages/create-bison-app/template/graphql/helpers.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* eslint-disable @typescript-eslint/ban-ts-comment */ - -/** Removes nullability from a type - * @example foo: string | null | undefined => foo: string | undefined - */ -export const prismaArg = (field: T | undefined | null): T | undefined => { - if (field === undefined || field === null) { - return undefined; - } - - return field; -}; - -/** Recursively removes nullability from nested object values */ -type ObjectWithoutNulls = { - [K in keyof T]: T[K] extends string | number | undefined | null - ? Exclude - : ObjectWithoutNulls>; -}; - -/** Removes nullability from the values of an object - * @example foo: {bar: string | null | undefined} => foo: {bar: string | undefined} - */ -export const prismaArgObject = > | undefined>( - field: T | null -): ObjectWithoutNulls => { - const newObject: T | null = field; - - if (!newObject || !field) { - // @ts-ignore - return undefined; - } - - Object.entries(field).forEach(([key, value]) => { - if (typeof value === 'object') { - // @ts-ignore - newObject[key] = prismaArgObject(value); - } else { - // @ts-ignore - newObject[key] = prismaArg(value); - } - }); - - // @ts-ignore - return newObject; -}; diff --git a/packages/create-bison-app/template/graphql/modules/index.ts b/packages/create-bison-app/template/graphql/modules/index.ts deleted file mode 100644 index c422d73e..00000000 --- a/packages/create-bison-app/template/graphql/modules/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './scalars'; -export * from './user'; -export * from './profile'; -export * from './shared'; diff --git a/packages/create-bison-app/template/graphql/modules/profile.ts b/packages/create-bison-app/template/graphql/modules/profile.ts deleted file mode 100644 index 362ec860..00000000 --- a/packages/create-bison-app/template/graphql/modules/profile.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { inputObjectType, objectType } from 'nexus'; - -import { NotFoundError } from '@/graphql/errors'; - -// Profile Type -export const Profile = objectType({ - name: 'Profile', - description: 'A User Profile', - definition(t) { - t.nonNull.id('id'); - t.nonNull.date('createdAt'); - t.nonNull.date('updatedAt'); - t.nonNull.string('firstName'); - t.nonNull.string('lastName'); - t.nonNull.field('user', { - type: 'User', - resolve: async (parent, _, context) => { - const user = await context.prisma.profile - .findUnique({ - where: { id: parent.id }, - }) - .user(); - - if (!user) { - throw new NotFoundError('User not found'); - } - - return user; - }, - }); - - t.string('fullName', { - description: 'The first and last name of a user', - resolve({ firstName, lastName }) { - return [firstName, lastName].filter((n) => Boolean(n)).join(' '); - }, - }); - }, -}); - -export const ProfileCreateInput = inputObjectType({ - name: 'ProfileCreateInput', - description: 'Profile Input for relational Create', - definition(t) { - t.nonNull.string('firstName'); - t.nonNull.string('lastName'); - }, -}); - -export const ProfileRelationalCreateInput = inputObjectType({ - name: 'ProfileRelationalCreateInput', - description: 'Input to Add a new user', - definition(t) { - t.nonNull.field('create', { type: 'ProfileCreateInput' }); - }, -}); diff --git a/packages/create-bison-app/template/graphql/modules/scalars.ts b/packages/create-bison-app/template/graphql/modules/scalars.ts deleted file mode 100644 index 130831a8..00000000 --- a/packages/create-bison-app/template/graphql/modules/scalars.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { - DateTimeResolver, - EmailAddressResolver, - JSONObjectResolver, - PhoneNumberResolver, - URLResolver, -} from 'graphql-scalars'; -import { asNexusMethod } from 'nexus'; - -export const JSON = asNexusMethod(JSONObjectResolver, 'json'); -export const DateTime = asNexusMethod(DateTimeResolver, 'date'); -export const Email = asNexusMethod(EmailAddressResolver, 'email'); -export const PhoneNumber = asNexusMethod(PhoneNumberResolver, 'phone'); -export const URL = asNexusMethod(URLResolver, 'url'); diff --git a/packages/create-bison-app/template/graphql/modules/shared.ts b/packages/create-bison-app/template/graphql/modules/shared.ts deleted file mode 100644 index ea38a9bb..00000000 --- a/packages/create-bison-app/template/graphql/modules/shared.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { enumType, inputObjectType } from 'nexus'; - -// the following items are migrated from the prisma plugin -export const SortOrder = enumType({ - name: 'SortOrder', - description: 'Sort direction for filtering queries (ascending or descending)', - members: ['asc', 'desc'], -}); - -export const StringFilter = inputObjectType({ - name: 'StringFilter', - description: 'A way to filter string fields. Meant to pass to prisma where clause', - definition(t) { - t.string('contains'); - t.string('endsWith'); - t.string('equals'); - t.string('gt'); - t.string('gte'); - t.list.nonNull.string('in'); - t.string('lt'); - t.string('lte'); - t.list.nonNull.string('notIn'); - t.string('startsWith'); - }, -}); - -export const NumberFilter = inputObjectType({ - name: 'NumberFilter', - description: 'A way to filter number fields. Meant to pass to prisma where clause', - definition(t) { - t.int('equals'); - t.int('gt'); - t.int('gte'); - t.list.nonNull.int('in'); - t.int('lt'); - t.int('lte'); - t.list.nonNull.int('notIn'); - }, -}); - -export const DateFilter = inputObjectType({ - name: 'DateFilter', - description: 'A way to filter date fields. Meant to pass to prisma where clause', - definition(t) { - t.date('equals'); - t.date('gt'); - t.date('gte'); - t.list.nonNull.date('in'); - t.date('lt'); - t.date('lte'); - t.list.nonNull.date('notIn'); - }, -}); diff --git a/packages/create-bison-app/template/graphql/modules/user.ts.ejs b/packages/create-bison-app/template/graphql/modules/user.ts.ejs deleted file mode 100644 index ca53a1b5..00000000 --- a/packages/create-bison-app/template/graphql/modules/user.ts.ejs +++ /dev/null @@ -1,265 +0,0 @@ -import { - objectType, - inputObjectType, - queryField, - mutationField, - stringArg, - arg, - nonNull, - enumType, - list, -} from 'nexus'; -import { Role } from '@prisma/client'; -import { UserInputError } from 'apollo-server-micro'; - -import { hashPassword, appJwtForUser, comparePasswords } from '@/services/auth'; -import { canAccess, isAdmin } from '@/services/permissions'; -import { prismaArgObject } from '@/graphql/helpers'; - -// User Type -export const User = objectType({ - name: 'User', - description: 'A User', - definition(t) { - t.nonNull.id('id'); - t.nonNull.date('createdAt'); - t.nonNull.date('updatedAt'); - t.nonNull.list.nonNull.field('roles', { type: 'Role' }); - - // Show email as null for unauthorized users - t.string('email', { - resolve: (profile, _args, ctx) => (canAccess(profile, ctx) ? profile.email : null), - }); - - t.field('profile', { - type: 'Profile', - resolve: (parent, _, context) => { - return context.prisma.user - .findUnique({ - where: { id: parent.id }, - }) - .profile(); - }, - }); - }, -}); - -// Auth Payload Type -export const AuthPayload = objectType({ - name: 'AuthPayload', - description: 'Payload returned if login or signup is successful', - definition(t) { - t.field('user', { type: 'User', description: 'The logged in user' }); - t.string('token', { - description: 'The current JWT token. Use in Authentication header', - }); - }, -}); - -// Queries -export const meQuery = queryField('me', { - type: 'User', - description: 'Returns the currently logged in user', - resolve: (_root, _args, ctx) => ctx.user, -}); - -export const findUsersQuery = queryField('users', { - type: list('User'), - authorize: (_root, _args, ctx) => isAdmin(ctx.user), - resolve: async (_root, args, ctx) => { - return await ctx.db.user.findMany({ ...args }); - }, -}); - -export const findUniqueUserQuery = queryField('user', { - type: 'User', - args: { - where: nonNull(arg({ type: 'UserWhereUniqueInput' })), - }, - resolve: async (_root, args, ctx) => { - return await ctx.db.user.findUnique({ where: prismaArgObject(args.where) }); - }, -}); - -// Mutations -export const loginMutation = mutationField('login', { - type: 'AuthPayload', - description: 'Login to an existing account', - args: { - email: nonNull(stringArg()), - password: nonNull(stringArg()), - }, - resolve: async (_root, args, ctx) => { - const { email, password } = args; - const user = await ctx.db.user.findUnique({ where: { email } }); - - if (!user) { - throw new UserInputError(`No user found for email: ${email}`, { - invalidArgs: { email: 'is invalid' }, - }); - } - - const valid = comparePasswords(password, user.password); - - if (!valid) { - throw new UserInputError('Invalid password', { - invalidArgs: { password: 'is invalid' }, - }); - } - - const token = appJwtForUser(user); - - return { - token, - user, - }; - }, -}); - -export const signupMutation = mutationField('signup', { - type: 'AuthPayload', - description: 'Signup for an account', - args: { - data: nonNull(arg({ type: 'SignupInput' })), - }, - resolve: async (_root, args, ctx) => { - const { data } = args; - const existingUser = await ctx.db.user.findUnique({ where: { email: data.email } }); - - if (existingUser) { - throw new UserInputError('Email already exists.', { - invalidArgs: { email: 'already exists' }, - }); - } - - // force role to user and hash the password - const updatedArgs = { - data: { - ...data, - roles: { set: [Role.USER] }, - password: hashPassword(data.password), - }, - }; - - const user = await ctx.db.user.create(updatedArgs); - const token = appJwtForUser(user); - - return { - user, - token, - }; - }, -}); - -export const createUserMutation = mutationField('createUser', { - type: 'User', - description: 'Create User for an account', - args: { - data: nonNull(arg({ type: 'UserCreateInput' })), - }, - authorize: (_root, _args, ctx) => isAdmin(ctx.user), - resolve: async (_root, args, ctx) => { - const { data } = args; - const existingUser = await ctx.db.user.findUnique({ where: { email: data.email } }); - - if (existingUser) { - throw new UserInputError('Email already exists.', { - invalidArgs: { email: 'already exists' }, - }); - } - - // force role to user and hash the password - const updatedArgs = { - data: { - ...prismaArgObject(data), - password: hashPassword(data.password), - }, - }; - - const user = await ctx.db.user.create(updatedArgs); - - return user; - }, -}); - -// Inputs -export const SignupProfileCreateInput = inputObjectType({ - name: 'SignupProfileCreateInput', - description: 'Input required for Profile Create on Signup.', - definition: (t) => { - t.nonNull.string('firstName'); - t.nonNull.string('lastName'); - }, -}); - -export const SignupProfileInput = inputObjectType({ - name: 'SignupProfileInput', - description: 'Input required for Profile on Signup.', - definition: (t) => { - t.nonNull.field('create', { - type: SignupProfileCreateInput, - }); - }, -}); - -export const SignupInput = inputObjectType({ - name: 'SignupInput', - description: 'Input required for a user to signup', - definition: (t) => { - t.nonNull.string('email'); - t.nonNull.string('password'); - t.nonNull.field('profile', { - type: SignupProfileInput, - }); - }, -}); - -// Manually added types that were previously in the prisma plugin -export const UserRole = enumType({ - name: 'Role', - members: Object.values(Role), -}); - -export const UserOrderByInput = inputObjectType({ - name: 'UserOrderByInput', - description: 'Order users by a specific field', - definition(t) { - t.field('email', { type: 'SortOrder' }); - t.field('createdAt', { type: 'SortOrder' }); - t.field('updatedAt', { type: 'SortOrder' }); - }, -}); - -export const UserWhereUniqueInput = inputObjectType({ - name: 'UserWhereUniqueInput', - description: 'Input to find users based on unique fields', - definition(t) { - t.id('id'); - t.string('email'); - }, -}); - -export const UserWhereInput = inputObjectType({ - name: 'UserWhereInput', - description: 'Input to find users based other fields', - definition(t) { - t.int('id'); - t.field('email', { type: 'StringFilter' }); - }, -}); - -export const UserCreateInput = inputObjectType({ - name: 'UserCreateInput', - description: 'Input to Add a new user', - definition(t) { - t.nonNull.string('email'); - t.nonNull.string('password'); - t.field('roles', { - type: list('Role'), - }); - - t.field('profile', { - type: 'ProfileRelationalCreateInput', - }); - }, -}); diff --git a/packages/create-bison-app/template/graphql/schema.ts b/packages/create-bison-app/template/graphql/schema.ts deleted file mode 100644 index 0804fe3c..00000000 --- a/packages/create-bison-app/template/graphql/schema.ts +++ /dev/null @@ -1,31 +0,0 @@ -import path from 'path'; - -import { declarativeWrappingPlugin, fieldAuthorizePlugin, makeSchema } from 'nexus'; - -import * as types from './modules'; - -import prettierConfig from '@/prettier.config'; - -const currentDirectory = process.cwd(); - -export const schema = makeSchema({ - types, - plugins: [fieldAuthorizePlugin(), declarativeWrappingPlugin()], - outputs: { - schema: path.join(currentDirectory, 'api.graphql'), - typegen: path.join(currentDirectory, 'types', 'nexus.d.ts'), - }, - contextType: { - module: path.join(currentDirectory, 'graphql', 'context.ts'), - export: 'Context', - }, - sourceTypes: { - modules: [ - { - module: '.prisma/client', - alias: 'PrismaClient', - }, - ], - }, - prettierConfig, -}); diff --git a/packages/create-bison-app/template/jest.config.js b/packages/create-bison-app/template/jest.config.js index 7363249c..ada7b5a2 100644 --- a/packages/create-bison-app/template/jest.config.js +++ b/packages/create-bison-app/template/jest.config.js @@ -16,10 +16,10 @@ const testPathIgnorePatterns = [ 'tests/e2e', ]; +/** @type {import('jest').Config} */ module.exports = { preset: 'ts-jest', rootDir: 'tests', - // testEnvironment: join(__dirname, 'tests', 'nexus-test-environment.js'), // setupFilesAfterEnv: ['/tests/jest.setup.ts'], globalSetup: '/jest.setup.js', globalTeardown: '/jest.teardown.js', @@ -31,6 +31,7 @@ module.exports = { }, }, }, + transformIgnorePatterns: ['/node_modules/(?!(@swc|@trpc))/'], moduleNameMapper: { ...moduleNameMapper, // Handle image imports diff --git a/packages/create-bison-app/template/lib/apolloClient.ts b/packages/create-bison-app/template/lib/apolloClient.ts deleted file mode 100644 index 9319292c..00000000 --- a/packages/create-bison-app/template/lib/apolloClient.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* eslint-disable no-restricted-globals */ -import { ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client'; -import { setContext } from '@apollo/client/link/context'; -import fetch from 'cross-fetch'; - -import { cookies } from './cookies'; - -import { LOGIN_TOKEN_KEY } from '@/constants'; - -export function createApolloClient(ctx?: Record) { - // Apollo needs an absolute URL when in SSR, so determine host - let host: string; - let protocol: string; - let hostUrl = process.env.API_URL; - - if (ctx) { - host = ctx?.req.headers['x-forwarded-host']; - protocol = ctx?.req.headers['x-forwarded-proto'] || 'http'; - hostUrl = `${protocol}://${host}`; - } else if (typeof location !== 'undefined') { - host = location.host; - protocol = location.protocol; - hostUrl = `${protocol}//${host}`; - } - - const uri = `${hostUrl}/api/graphql`; - - const httpLink = createHttpLink({ - uri, - fetch, - }); - - const authLink = setContext((_, { headers }) => { - // get the authentication token if it exists - const token = cookies().get(LOGIN_TOKEN_KEY); - // return the headers to the context so httpLink can read them - return { - headers: { - ...headers, - authorization: token ? `Bearer ${token}` : '', - }, - }; - }); - - const client = new ApolloClient({ - link: authLink.concat(httpLink), - cache: new InMemoryCache(), - }); - - return client; -} diff --git a/packages/create-bison-app/template/lib/prisma.ts b/packages/create-bison-app/template/lib/prisma.ts index 5fdb6472..30ec721d 100644 --- a/packages/create-bison-app/template/lib/prisma.ts +++ b/packages/create-bison-app/template/lib/prisma.ts @@ -1,4 +1,4 @@ -import { PrismaClient } from '@prisma/client'; +import { Prisma, PrismaClient } from '@prisma/client'; /** * Instantiate prisma client for Next.js: @@ -10,10 +10,13 @@ declare global { var prisma: PrismaClient | undefined; } +// Set default prisma logs. More logs in debug mode. +const logOptions: Prisma.LogLevel[] = process.env.DEBUG ? ['query', 'error'] : ['error']; + export const prisma = global.prisma || new PrismaClient({ - log: ['query'], + log: logOptions, }); if (process.env.NODE_ENV !== 'production') { diff --git a/packages/create-bison-app/template/lib/trpc.ts b/packages/create-bison-app/template/lib/trpc.ts new file mode 100644 index 00000000..feb938fa --- /dev/null +++ b/packages/create-bison-app/template/lib/trpc.ts @@ -0,0 +1,62 @@ +import type { inferProcedureOutput } from '@trpc/server'; +import superjson from 'superjson'; +import { createTRPCNext } from '@trpc/next'; +import { httpBatchLink } from '@trpc/client'; + +import { cookies } from './cookies'; + +import { LOGIN_TOKEN_KEY } from '@/constants'; +import type { AppRouter } from '@/server/routers/_app'; + +// exporting this so that we consistently use the same transformer everywhere. +export const transformer = superjson; + +/** + * A set of strongly-typed React hooks from your `AppRouter` type signature with `createTRPCNext`. + * @link https://trpc.io/docs/react#3-create-trpc-hooks + */ +export const trpc = createTRPCNext({ + config({ ctx }) { + let host: string; + let protocol: string; + let hostUrl = process.env.API_URL; + + if (ctx?.req) { + host = ctx.req.headers['x-forwarded-host'] as string; + protocol = (ctx.req.headers['x-forwarded-proto'] as string) || 'http'; + hostUrl = `${protocol}://${host}`; + } else if (typeof window !== 'undefined' && typeof window.location !== 'undefined') { + host = window.location.host; + protocol = window.location.protocol; + hostUrl = `${protocol}//${host}`; + } + + const url = `${hostUrl}/api/trpc`; + + return { + transformer, + links: [ + httpBatchLink({ + url, + maxURLLength: 2083, // a suitable size + headers: () => { + const token = cookies().get(LOGIN_TOKEN_KEY); + return { + authorization: token ? `Bearer ${token}` : '', + }; + }, + }), + ], + }; + }, + ssr: false, +}); + +/** + * This is a helper method to infer the output of a query resolver + * @example type HelloOutput = inferQueryOutput<'hello'> + */ +export type inferQueryOutput< + TRouteKey extends keyof AppRouter['_def']['procedures'], + TProcedureKey extends keyof AppRouter['_def']['procedures'][TRouteKey] +> = inferProcedureOutput; diff --git a/packages/create-bison-app/template/package.json.ejs b/packages/create-bison-app/template/package.json.ejs index 6c981476..c9236b8d 100644 --- a/packages/create-bison-app/template/package.json.ejs +++ b/packages/create-bison-app/template/package.json.ejs @@ -7,11 +7,8 @@ <% } -%> "scripts": { "build": "yarn ts-node ./scripts/buildProd", - "build:types": "yarn build:prisma && touch types.ts && yarn build:nexus && yarn codegen", "build:prisma": "prisma generate", "build:next": "next build", - "build:nexus": "cross-env NODE_ENV=development yarn ts-node --transpile-only graphql/schema.ts", - "codegen": "graphql-codegen --config codegen.yml", "cypress:open": "cypress open", "cypress:run": "cypress run", "db:migrate": "prisma migrate dev", @@ -21,13 +18,12 @@ "db:seed": "yarn prisma db seed", "db:seed:prod": "cross-env APP_ENV=production prisma db seed", "db:setup": "yarn db:reset", - "dev": "concurrently -n \"WATCHERS,NEXT\" -c \"black.bgYellow.dim,black.bgCyan.dim\" \"yarn watch:all\" \"next dev\"", + "dev": "next dev", "dev:typecheck": "tsc --noEmit", "g:cell": "hygen cell new --name", "g:component": "hygen component new --name", - "g:graphql": "hygen graphql new --name", + "g:trpc": "hygen trpc new --name", "g:page": "hygen page new --name", - "g:migration": "yarn -s prisma migrate dev", "g:test:component": "hygen test component --name", "g:test:factory": "hygen test factory --name", "g:test:request": "hygen test request --name", @@ -35,7 +31,7 @@ "lint": "yarn eslint . --ext .ts,.tsx --ignore-pattern tmp", "lint:fix": "yarn lint --fix", "run:script": "yarn ts-node prisma/scripts/run.ts -f", - "setup:dev": "yarn db:deploy && yarn build:types && yarn db:seed", + "setup:dev": "yarn db:deploy && yarn db:seed", "start": "next start -p $PORT", "test": "yarn withEnv:test jest --runInBand --watch", "test:ci": "yarn withEnv:test jest --runInBand", @@ -44,42 +40,33 @@ "test:server": "next start --port 3001", "ts-node": "ts-node-dev --project tsconfig.cjs.json -r tsconfig-paths/register", "withEnv:test": "dotenv -c test --", - "watch:all": "concurrently -n \"NEXUS,GQLCODEGEN,TYPESCRIPT\" -c \"black.bgGreen.dim,black.bgBlue.dim,white.bgMagenta.dim\" \"yarn watch:nexus\" \"yarn watch:codegen\" \"yarn watch:ts\"", - "watch:codegen": "yarn codegen --watch", - "watch:nexus": "yarn ts-node --transpile-only --respawn --watch graphql/schema.ts,prisma/schema.prisma graphql/schema.ts", "watch:ts": "yarn dev:typecheck --watch" }, "dependencies": { - "@apollo/client": "^3.6.1", "@chakra-ui/react": "^2.2.0", "@chakra-ui/theme": "^2.1.0", "@emotion/react": "^11.0.0", "@emotion/styled": "^11.0.0", "@prisma/client": "^4.0.0", - "apollo-server-micro": "^3.6.7", + "@tanstack/react-query": "^4.12.0", + "@trpc/client": "^10.0.0-proxy-beta.25", + "@trpc/next": "^10.0.0-proxy-beta.25", + "@trpc/react-query": "^10.0.0-proxy-beta.25", + "@trpc/server": "^10.0.0-proxy-beta.25", "bcryptjs": "^2.4.3", "cross-fetch": "3.0.5", "framer-motion": "^4", - "graphql": "^16.4.0", - "graphql-scalars": "^1.17.0", - "graphql-type-json": "^0.3.2", "jsonwebtoken": "^8.5.1", - "micro": "^9.3.4", "next": "12.1.5", - "nexus": "^1.3.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.30.0", - "universal-cookie": "^4.0.4" + "superjson": "^1.10.0", + "universal-cookie": "^4.0.4", + "zod": "^3.19.1" }, "devDependencies": { - "@apollo/react-testing": "^4.0.0", - "@graphql-codegen/add": "^3.1.1", - "@graphql-codegen/cli": "^2.6.2", - "@graphql-codegen/typescript": "^2.4.8", - "@graphql-codegen/typescript-operations": "^2.3.5", - "@graphql-codegen/typescript-react-apollo": "^3.2.11", - "@graphql-codegen/typescript-resolvers": "^2.6.1", + "@swc/jest": "^0.2.23", "@testing-library/cypress": "^8.0.0", "@testing-library/dom": "^8.1.0", "@testing-library/jest-dom": "^5.14.1", @@ -88,14 +75,13 @@ "@types/bcryptjs": "^2.4.2", "@types/chance": "^1.1.3", "@types/jest": "^26.0.6", + "@types/jsonwebtoken": "^8.5.9", "@types/node": "^16.9.6", "@types/pg": "^8.6.1", "@types/react": "^18.0.14", - "@types/supertest": "^2.0.10", "@vercel/node": "^1.7.3", "chance": "^1.1.8", "commander": "^8.1.0", - "concurrently": "^7.1.0", "cross-env": "^7.0.3", "cypress": "^10.3.0", "dotenv-cli": "^6.0.0", @@ -110,13 +96,11 @@ "jest": "^27.2.1", "jest-environment-node": "^27.2.0", "lint-staged": "^10.2.11", - "msw": "^0.20.1", "nanoid": "^3.1.10", "pg": "^8.3.0", "prettier": "^2.6.2", "prisma": "^4.0.0", "start-server-and-test": "^1.14.0", - "supertest": "^4.0.2", "ts-jest": "^27.0.5", "ts-node-dev": "^2.0.0-0", "tsconfig-paths": "^4.0.0", @@ -131,8 +115,7 @@ } }, "lint-staged": { - "*.{ts,tsx}": "yarn lint", - "*.{graphql}": "prettier --write" + "*.{ts,tsx}": "yarn lint" }, "prisma": { "seed": "yarn ts-node prisma/seed.ts" diff --git a/packages/create-bison-app/template/pages/_app.tsx b/packages/create-bison-app/template/pages/_app.tsx index 3493e5bf..8858b7ec 100644 --- a/packages/create-bison-app/template/pages/_app.tsx +++ b/packages/create-bison-app/template/pages/_app.tsx @@ -4,6 +4,7 @@ import dynamic from 'next/dynamic'; import { AllProviders } from '@/components/AllProviders'; import { useAuth } from '@/context/auth'; +import { trpc } from '@/lib/trpc'; /** * Dynamically load layouts. This codesplits and prevents code from the logged in layout from being @@ -40,4 +41,4 @@ function App({ pageProps, Component }: AppProps) { ); } -export default App; +export default trpc.withTRPC(App); diff --git a/packages/create-bison-app/template/pages/api/graphql.ts b/packages/create-bison-app/template/pages/api/graphql.ts deleted file mode 100644 index e7d6ab92..00000000 --- a/packages/create-bison-app/template/pages/api/graphql.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { - ApolloServerPluginCacheControl, - ApolloServerPluginLandingPageGraphQLPlayground, - ApolloServerPluginLandingPageDisabled, -} from 'apollo-server-core'; -import { ApolloServer } from 'apollo-server-micro'; -import type { NextApiRequest, NextApiResponse } from 'next'; - -import { createContext } from '@/graphql/context'; -import { schema } from '@/graphql/schema'; - -export const GRAPHQL_PATH = '/api/graphql'; - -// this config block is REQUIRED on Vercel! It stops the body of incoming HTTP requests from being parsed -export const config = { - api: { - bodyParser: false, - }, -}; - -export const server = new ApolloServer({ - schema, - introspection: true, - context: createContext, - plugins: [ - process.env.NODE_ENV === 'production' - ? ApolloServerPluginLandingPageDisabled() - : ApolloServerPluginLandingPageGraphQLPlayground(), - ApolloServerPluginCacheControl({ - calculateHttpHeaders: false, - }), - ], -}); - -const serverStart = server.start(); - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - await serverStart; - - return server.createHandler({ path: GRAPHQL_PATH })(req, res); -} diff --git a/packages/create-bison-app/template/pages/api/trpc/[trpc].ts b/packages/create-bison-app/template/pages/api/trpc/[trpc].ts new file mode 100644 index 00000000..e4589172 --- /dev/null +++ b/packages/create-bison-app/template/pages/api/trpc/[trpc].ts @@ -0,0 +1,36 @@ +/** + * This file contains tRPC's HTTP response handler + */ +import * as trpcNext from '@trpc/server/adapters/next'; + +import { appRouter } from '@/server/routers/_app'; +import { createContext } from '@/server/context'; + +export default trpcNext.createNextApiHandler({ + router: appRouter, + /** + * @link https://trpc.io/docs/context + */ + createContext, + /** + * @link https://trpc.io/docs/error-handling + */ + onError({ error }) { + if (error.code === 'INTERNAL_SERVER_ERROR') { + // send to bug reporting + console.error('Something went wrong', error); + } + }, + /** + * Enable query batching + */ + batching: { + enabled: true, + }, + /** + * @link https://trpc.io/docs/caching#api-response-caching + */ + // responseMeta() { + // // ... + // }, +}); diff --git a/packages/create-bison-app/template/prisma/scripts/exampleUserScript.ts b/packages/create-bison-app/template/prisma/scripts/exampleUserScript.ts index c85f60d3..6662c2a8 100644 --- a/packages/create-bison-app/template/prisma/scripts/exampleUserScript.ts +++ b/packages/create-bison-app/template/prisma/scripts/exampleUserScript.ts @@ -1,7 +1,6 @@ -import { Prisma } from '@prisma/client'; +import { Prisma, Role } from '@prisma/client'; import { hashPassword } from '@/services/auth'; -import { Role } from '@/types'; import { seedUsers } from '@/prisma/seeds/users'; import { prisma } from '@/lib/prisma'; diff --git a/packages/create-bison-app/template/prisma/seeds/users/prismaRunner.ts b/packages/create-bison-app/template/prisma/seeds/users/prismaRunner.ts index 87dfdf6a..bcd0288a 100644 --- a/packages/create-bison-app/template/prisma/seeds/users/prismaRunner.ts +++ b/packages/create-bison-app/template/prisma/seeds/users/prismaRunner.ts @@ -1,7 +1,6 @@ -import { Prisma } from '@prisma/client'; +import { Prisma, User } from '@prisma/client'; import { prisma } from '@/lib/prisma'; -import { User } from '@/types'; type SeedUserResult = Pick; diff --git a/packages/create-bison-app/template/scripts/buildProd.ts b/packages/create-bison-app/template/scripts/buildProd.ts index 802fd96a..820f5b23 100644 --- a/packages/create-bison-app/template/scripts/buildProd.ts +++ b/packages/create-bison-app/template/scripts/buildProd.ts @@ -1,7 +1,7 @@ export {}; const spawn = require('child_process').spawn; -const DEFAULT_BUILD_COMMAND = `yarn build:types && yarn build:next`; +const DEFAULT_BUILD_COMMAND = `yarn build:next`; /** * This builds the production app. diff --git a/packages/create-bison-app/template/server/context.ts b/packages/create-bison-app/template/server/context.ts new file mode 100644 index 00000000..90a84b51 --- /dev/null +++ b/packages/create-bison-app/template/server/context.ts @@ -0,0 +1,30 @@ +import * as trpc from '@trpc/server'; +import * as trpcNext from '@trpc/server/adapters/next'; +import { User } from '@prisma/client'; + +import { verifyAuthHeader } from '@/services/auth'; +import { prisma } from '@/lib/prisma'; + +/** + * Creates context for an incoming request + * @link https://trpc.io/docs/context + */ +export const createContext = async ({ req }: trpcNext.CreateNextContextOptions) => { + const authHeader = verifyAuthHeader(req.headers.authorization); + + let user: User | null = null; + + // get all user props + if (authHeader) { + user = await prisma.user.findUnique({ where: { id: authHeader.userId } }); + } + + // for API-response caching see https://trpc.io/docs/caching + return { + db: prisma, + prisma, + user, + }; +}; + +export type Context = trpc.inferAsyncReturnType; diff --git a/packages/create-bison-app/template/server/middleware/auth.ts b/packages/create-bison-app/template/server/middleware/auth.ts new file mode 100644 index 00000000..472dc225 --- /dev/null +++ b/packages/create-bison-app/template/server/middleware/auth.ts @@ -0,0 +1,35 @@ +import { TRPCError } from '@trpc/server'; + +import { t } from '@/server/trpc'; +import { isAdmin } from '@/services/permissions'; + +const authMiddleware = t.middleware(({ ctx, next }) => { + if (!ctx.user) { + throw new TRPCError({ code: 'UNAUTHORIZED' }); + } + + return next({ + ctx: { + ...ctx, + // infers that `user` is non-nullable to downstream procedures + user: ctx.user, + }, + }); +}); + +const adminMiddleware = t.middleware(({ ctx, next }) => { + if (!isAdmin(ctx.user)) { + throw new TRPCError({ code: 'UNAUTHORIZED' }); + } + + return next({ + ctx: { + ...ctx, + // infers that `user` is non-nullable to downstream procedures + user: ctx.user, + }, + }); +}); + +export const protectedProcedure = t.procedure.use(authMiddleware); +export const adminProcedure = t.procedure.use(adminMiddleware); diff --git a/packages/create-bison-app/template/server/middleware/timing.ts b/packages/create-bison-app/template/server/middleware/timing.ts new file mode 100644 index 00000000..2c2ba929 --- /dev/null +++ b/packages/create-bison-app/template/server/middleware/timing.ts @@ -0,0 +1,17 @@ +import { t } from '@/server/trpc'; + +const timingMiddleware = t.middleware(async ({ path, type, next }) => { + // Don't log timing in tests. + if (process.env.NODE_ENV === 'test') return await next(); + + const start = Date.now(); + const result = await next(); + const durationMs = Date.now() - start; + result.ok + ? console.log('OK request timing:', { path, type, durationMs }) + : console.log('Non-OK request timing', { path, type, durationMs }); + + return result; +}); + +export const timingProcedure = t.procedure.use(timingMiddleware); diff --git a/packages/create-bison-app/template/server/routers/_app.ts b/packages/create-bison-app/template/server/routers/_app.ts new file mode 100644 index 00000000..2ca55092 --- /dev/null +++ b/packages/create-bison-app/template/server/routers/_app.ts @@ -0,0 +1,19 @@ +/** + * This file contains the root router of your tRPC-backend + */ + +import { userRouter } from './user'; + +import { t } from '@/server/trpc'; + +/** + * Create your application's root router + * If you want to use SSG, you need export this + * @link https://trpc.io/docs/ssg + * @link https://trpc.io/docs/router + */ +export const appRouter = t.router({ + user: userRouter, +}); + +export type AppRouter = typeof appRouter; diff --git a/packages/create-bison-app/template/server/routers/user.ts b/packages/create-bison-app/template/server/routers/user.ts new file mode 100644 index 00000000..f460ffb5 --- /dev/null +++ b/packages/create-bison-app/template/server/routers/user.ts @@ -0,0 +1,146 @@ +import { Prisma, Role } from '@prisma/client'; +import { z } from 'zod'; + +import { BisonError, t } from '@/server/trpc'; +import { appJwtForUser, comparePasswords, hashPassword } from '@/services/auth'; +import { adminProcedure, protectedProcedure } from '@/server/middleware/auth'; +import { isAdmin } from '@/services/permissions'; + +export const defaultUserSelect = Prisma.validator()({ + id: true, + email: true, + createdAt: true, + updatedAt: true, + roles: true, + profile: { select: { firstName: true, lastName: true } }, +}); + +export const userRouter = t.router({ + me: protectedProcedure.query(async function resolve({ ctx }) { + return ctx.user; + }), + findMany: adminProcedure + .input(z.object({ id: z.string().optional(), email: z.string().optional() }).optional()) + .query(({ ctx, input }) => ctx.db.user.findMany({ where: input, select: defaultUserSelect })), + find: t.procedure + .input(z.object({ id: z.string().optional(), email: z.string().optional() })) + .query(async ({ ctx, input }) => { + const user = await ctx.db.user.findUniqueOrThrow({ where: input, select: defaultUserSelect }); + + if (!isAdmin(ctx.user) && user.id !== ctx.user?.id) { + return { ...user, email: null }; + } + + return user; + }), + login: t.procedure + .input(z.object({ email: z.string(), password: z.string() })) + .mutation(async ({ ctx, input: { email, password } }) => { + const result = await ctx.db.user.findUnique({ + where: { email }, + select: { ...defaultUserSelect, password: true }, + }); + + if (!result) { + throw new BisonError({ + message: `No user found for email: ${email}`, + code: 'BAD_REQUEST', + invalidArgs: { email: `No user found for email: ${email}` }, + }); + } + + const { password: userPassword, ...user } = result; + + const valid = comparePasswords(password, userPassword); + + if (!valid) { + throw new BisonError({ + message: 'Invalid password', + code: 'BAD_REQUEST', + invalidArgs: { password: 'Invalid password' }, + }); + } + + const token = appJwtForUser(user); + + return { + token, + user, + }; + }), + signup: t.procedure + .input( + z.object({ + email: z.string(), + password: z.string(), + profile: z.object({ firstName: z.string(), lastName: z.string() }), + }) + ) + .mutation(async ({ ctx, input: { email, password, profile } }) => { + const existingUser = await ctx.db.user.findUnique({ + where: { email }, + select: defaultUserSelect, + }); + + if (existingUser) { + throw new BisonError({ + message: 'Email already exists.', + code: 'BAD_REQUEST', + invalidArgs: { email: 'Email already exists.' }, + }); + } + + // force role to user and hash the password + const user = await ctx.db.user.create({ + data: { + email, + profile: { create: profile }, + roles: { set: [Role.USER] }, + password: hashPassword(password), + }, + select: defaultUserSelect, + }); + + const token = appJwtForUser(user); + + return { + user, + token, + }; + }), + create: adminProcedure + .input( + z.object({ + email: z.string(), + password: z.string(), + roles: z.array(z.nativeEnum(Role)).optional(), + profile: z.object({ firstName: z.string(), lastName: z.string() }).optional(), + }) + ) + .mutation(async ({ ctx, input: { email, password, roles = [Role.USER], profile } }) => { + const existingUser = await ctx.db.user.findUnique({ where: { email } }); + + if (existingUser) { + throw new BisonError({ + message: 'Email already exists.', + code: 'BAD_REQUEST', + invalidArgs: { email: 'Email already exists.' }, + }); + } + + // force role to user and hash the password + const updatedArgs = { + data: { + email, + roles, + profile, + password: hashPassword(password), + }, + select: defaultUserSelect, + }; + + const user = await ctx.db.user.create(updatedArgs); + + return user; + }), +}); diff --git a/packages/create-bison-app/template/server/trpc.ts b/packages/create-bison-app/template/server/trpc.ts new file mode 100644 index 00000000..fd320040 --- /dev/null +++ b/packages/create-bison-app/template/server/trpc.ts @@ -0,0 +1,35 @@ +import { initTRPC, TRPCError } from '@trpc/server'; +import { TRPC_ERROR_CODE_KEY } from '@trpc/server/rpc'; + +import type { Context } from './context'; + +import { transformer } from '@/lib/trpc'; + +export const t = initTRPC.context().create({ + transformer, + errorFormatter({ shape, error }) { + return { + ...shape, + data: { + ...shape.data, + invalidArgs: error instanceof BisonError ? error.invalidArgs : undefined, + message: error.message, + }, + }; + }, +}); + +export class BisonError extends TRPCError { + invalidArgs?: Record; + constructor(data: { + message?: string; + code: TRPC_ERROR_CODE_KEY; + cause?: unknown; + invalidArgs?: Record; + }) { + const { invalidArgs, ...superData } = data; + super(superData); + + this.invalidArgs = invalidArgs; + } +} diff --git a/packages/create-bison-app/template/services/permissions.ts b/packages/create-bison-app/template/services/permissions.ts index 45ceba08..6037d486 100644 --- a/packages/create-bison-app/template/services/permissions.ts +++ b/packages/create-bison-app/template/services/permissions.ts @@ -1,6 +1,6 @@ import { Role, Profile, User } from '@prisma/client'; -import { Context } from '@/graphql/context'; +import { Context } from '@/server/context'; /** * Returns true if the user has a role of admin diff --git a/packages/create-bison-app/template/tests/e2e/login.cy.ts b/packages/create-bison-app/template/tests/e2e/login.cy.ts index 8991d0f9..e6400115 100644 --- a/packages/create-bison-app/template/tests/e2e/login.cy.ts +++ b/packages/create-bison-app/template/tests/e2e/login.cy.ts @@ -6,7 +6,10 @@ describe('Login', () => { const email = 'nowayshouldIexist@wee.net'; const password = 'test1234'; - cy.intercept('POST', '/api/graphql').as('loginMutation'); + cy.intercept({ method: 'POST', hostname: 'localhost', url: '/api/trpc/user.login**' }).as( + 'loginMutation' + ); + cy.visit('/'); cy.findByText(/login/i).click(); @@ -16,7 +19,7 @@ describe('Login', () => { cy.findAllByRole('button', { name: /login/i }).click(); cy.wait('@loginMutation'); - cy.findByText(/is invalid/i).should('exist'); + cy.findByText(/No user found/i).should('exist'); }); }); @@ -26,7 +29,10 @@ describe('Login', () => { // note: async/await breaks cypress 😭 cy.task('factory', { name: 'User', attrs }).then((user) => { - cy.intercept('POST', '/api/graphql').as('loginMutation'); + cy.intercept({ method: 'POST', hostname: 'localhost', url: '/api/trpc/user.login**' }).as( + 'loginMutation' + ); + cy.visit('/'); cy.findByText(/login/i).click(); diff --git a/packages/create-bison-app/template/tests/helpers/graphQLRequest.ts b/packages/create-bison-app/template/tests/helpers/graphQLRequest.ts deleted file mode 100644 index 96ab728b..00000000 --- a/packages/create-bison-app/template/tests/helpers/graphQLRequest.ts +++ /dev/null @@ -1,43 +0,0 @@ -import request from 'supertest'; -import { User } from '@prisma/client'; - -import server, { GRAPHQL_PATH } from '@/pages/api/graphql'; -import { appJwtForUser } from '@/services/auth'; -import { disconnect } from '@/lib/prisma'; - -/** A convenience method to call graphQL queries */ -export const graphQLRequest = async (options: GraphQLRequestOptions): Promise => { - const response = await request(server) - .post(GRAPHQL_PATH) - .set('Content-Type', 'application/json') - .set('Accept', 'application/json') - .send(options); - - await disconnect(); - - return response; -}; - -/** A convenience method to call graphQL queries as a specific user */ -export const graphQLRequestAsUser = async ( - user: Partial, - options: GraphQLRequestOptions -): Promise => { - const token = appJwtForUser(user); - - const response = await request(server) - .post(GRAPHQL_PATH) - .set('Content-Type', 'application/json') - .set('Accept', 'application/json') - .set('Authorization', `Bearer ${token}`) - .send(options); - - await disconnect(); - - return response; -}; - -interface GraphQLRequestOptions { - query?: string; - variables?: Record; -} diff --git a/packages/create-bison-app/template/tests/helpers/index.ts b/packages/create-bison-app/template/tests/helpers/index.ts index 896dbf44..a750ba7a 100644 --- a/packages/create-bison-app/template/tests/helpers/index.ts +++ b/packages/create-bison-app/template/tests/helpers/index.ts @@ -1,4 +1,4 @@ export { prisma, connect, disconnect } from '@/lib/prisma'; -export * from './graphQLRequest'; +export * from './trpcRequest'; export * from './db'; export * from './buildPrismaIncludeFromAttrs'; diff --git a/packages/create-bison-app/template/tests/helpers/trpcRequest.ts b/packages/create-bison-app/template/tests/helpers/trpcRequest.ts new file mode 100644 index 00000000..f262a185 --- /dev/null +++ b/packages/create-bison-app/template/tests/helpers/trpcRequest.ts @@ -0,0 +1,17 @@ +import { User } from '@prisma/client'; + +import { appRouter } from '@/server/routers/_app'; +import { prisma } from '@/lib/prisma'; + +function createTestContext(user?: User) { + return { + db: prisma, + prisma, + user: user || null, + }; +} + +/** A convenience method to call tRPC queries */ +export const trpcRequest = (user?: Partial) => { + return appRouter.createCaller(createTestContext(user as User)); +}; diff --git a/packages/create-bison-app/template/tests/requests/user/createUser.test.ts b/packages/create-bison-app/template/tests/requests/user/createUser.test.ts index 38c9df7a..5ae1eb28 100644 --- a/packages/create-bison-app/template/tests/requests/user/createUser.test.ts +++ b/packages/create-bison-app/template/tests/requests/user/createUser.test.ts @@ -1,60 +1,34 @@ -import { GraphQLError } from 'graphql'; +import { Role } from '@prisma/client'; -import { resetDB, disconnect, graphQLRequestAsUser } from '@/tests/helpers'; +import { resetDB, disconnect, trpcRequest } from '@/tests/helpers'; import { UserFactory } from '@/tests/factories/user'; -import { Role, UserCreateInput } from '@/types'; beforeEach(async () => resetDB()); afterAll(async () => disconnect()); -describe('User createUser mutation', () => { +describe('User create mutation', () => { describe('non-admin', () => { it('returns a Forbidden error', async () => { - const query = ` - mutation CREATEUSER($data: UserCreateInput!) { - createUser(data: $data) { - id - email - } - } - `; - const user = await UserFactory.create({ email: 'foo@wee.net' }); - const variables: { data: UserCreateInput } = { - data: { email: user.email, password: 'fake' }, - }; - - const response = await graphQLRequestAsUser(user, { query, variables }); - const errorMessages = response.body.errors.map((e: GraphQLError) => e.message); - - expect(errorMessages).toMatchInlineSnapshot(` - Array [ - "Not authorized", - ] - `); + await expect( + trpcRequest(user).user.create({ + email: user.email, + password: 'fake', + }) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"UNAUTHORIZED"`); }); }); describe('admin', () => { it('allows setting role', async () => { - const query = ` - mutation CREATEUSER($data: UserCreateInput!) { - createUser(data: $data) { - id - roles - } - } - `; - const admin = await UserFactory.create({ roles: { set: [Role.ADMIN] } }); - const variables: { data: UserCreateInput } = { - data: { email: 'hello@wee.net', password: 'fake', roles: [Role.ADMIN] }, - }; - - const response = await graphQLRequestAsUser(admin, { query, variables }); - const user = response.body.data.createUser; + const user = await trpcRequest(admin).user.create({ + email: 'hello@wee.net', + password: 'fake', + roles: [Role.ADMIN], + }); const expectedRoles = [Role.ADMIN]; expect(user.id).not.toBeNull(); diff --git a/packages/create-bison-app/template/tests/requests/user/login.test.ts b/packages/create-bison-app/template/tests/requests/user/login.test.ts index 320ad878..cf754bcc 100644 --- a/packages/create-bison-app/template/tests/requests/user/login.test.ts +++ b/packages/create-bison-app/template/tests/requests/user/login.test.ts @@ -1,36 +1,17 @@ -import { GraphQLError } from 'graphql'; - -import { graphQLRequest, resetDB, disconnect } from '@/tests/helpers'; +import { trpcRequest, resetDB, disconnect } from '@/tests/helpers'; import { UserFactory } from '@/tests/factories/user'; -import { LoginMutationVariables } from '@/types'; beforeEach(async () => resetDB()); afterAll(async () => disconnect()); describe('login mutation', () => { - const query = ` - mutation LOGIN($email: String!, $password: String!) { - login(email: $email, password: $password) { - user { - email - } - } - } - `; - describe('invalid email', () => { it('returns an Authentication error', async () => { await UserFactory.create({ email: 'foo@wee.net' }); - const variables: LoginMutationVariables = { email: 'fake', password: 'fake' }; - const response = await graphQLRequest({ query, variables }); - const errorMessages = response.body.errors.map((e: GraphQLError) => e.message); - - expect(errorMessages).toMatchInlineSnapshot(` - Array [ - "No user found for email: fake", - ] - `); + await expect( + trpcRequest().user.login({ email: 'fake', password: 'fake' }) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"No user found for email: fake"`); }); }); @@ -38,15 +19,9 @@ describe('login mutation', () => { it('returns an Authentication error', async () => { const user = await UserFactory.create({ email: 'foo@wee.net' }); - const variables: LoginMutationVariables = { email: user.email, password: 'fake' }; - const response = await graphQLRequest({ query, variables }); - const errorMessages = response.body.errors.map((e: GraphQLError) => e.message); - - expect(errorMessages).toMatchInlineSnapshot(` - Array [ - "Invalid password", - ] - `); + await expect( + trpcRequest().user.login({ email: user.email, password: 'fake' }) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"Invalid password"`); }); }); @@ -59,20 +34,14 @@ describe('login mutation', () => { password, }); - const variables: LoginMutationVariables = { email: user.email, password }; - const response = await graphQLRequest({ query, variables }); + const { + token, + user: { email, roles }, + } = await trpcRequest().user.login({ email: user.email, password }); - expect(response.body).toMatchInlineSnapshot(` - Object { - "data": Object { - "login": Object { - "user": Object { - "email": null, - }, - }, - }, - } - `); + expect(token).toBeTruthy(); + expect(typeof token).toEqual('string'); + expect({ email, roles }).toEqual({ email: 'test@wee.net', roles: ['USER'] }); }); }); }); diff --git a/packages/create-bison-app/template/tests/requests/user/me.test.ts b/packages/create-bison-app/template/tests/requests/user/me.test.ts index f717a51f..c7bcafc2 100644 --- a/packages/create-bison-app/template/tests/requests/user/me.test.ts +++ b/packages/create-bison-app/template/tests/requests/user/me.test.ts @@ -1,4 +1,4 @@ -import { graphQLRequest, graphQLRequestAsUser, resetDB, disconnect } from '@/tests/helpers'; +import { trpcRequest, resetDB, disconnect } from '@/tests/helpers'; import { UserFactory } from '@/tests/factories/user'; beforeEach(async () => resetDB()); @@ -7,55 +7,21 @@ afterAll(async () => disconnect()); describe('me query', () => { describe('not logged in', () => { it('returns null ', async () => { - const query = ` - query ME { - me { - id - } - } - `; - - const response = await graphQLRequest({ query }); - - expect(response.body).toMatchInlineSnapshot(` - Object { - "data": Object { - "me": null, - }, - } - `); + await expect(trpcRequest().user.me()).rejects.toMatchInlineSnapshot( + `[TRPCError: UNAUTHORIZED]` + ); }); }); describe('logged in', () => { it('returns user data', async () => { - const query = ` - query ME { - me { - email - roles - } - } - `; - const user = await UserFactory.create({ email: 'foo@wee.net', }); - const response = await graphQLRequestAsUser(user, { query }); + const { email, roles } = await trpcRequest(user).user.me(); - expect(response.body).toMatchInlineSnapshot(` - Object { - "data": Object { - "me": Object { - "email": "foo@wee.net", - "roles": Array [ - "USER", - ], - }, - }, - } - `); + expect({ email, roles }).toEqual({ email: 'foo@wee.net', roles: ['USER'] }); }); }); }); diff --git a/packages/create-bison-app/template/tests/requests/user/signup.test.ts b/packages/create-bison-app/template/tests/requests/user/signup.test.ts index dad4f17f..b51110f1 100644 --- a/packages/create-bison-app/template/tests/requests/user/signup.test.ts +++ b/packages/create-bison-app/template/tests/requests/user/signup.test.ts @@ -1,8 +1,7 @@ import { Role } from '@prisma/client'; import Chance from 'chance'; -import { GraphQLError } from 'graphql'; -import { graphQLRequest, resetDB, disconnect } from '@/tests/helpers'; +import { trpcRequest, resetDB, disconnect } from '@/tests/helpers'; import { UserFactory } from '@/tests/factories/user'; const chance = new Chance(); @@ -13,96 +12,48 @@ afterAll(async () => disconnect()); describe('User signup mutation', () => { describe('existing email', () => { it('returns a UserInput error', async () => { - const query = ` - mutation SIGNUP($data: SignupInput!) { - signup(data: $data) { - token - user { - id - } - } - } - `; - const user = await UserFactory.create({ email: 'foo@wee.net' }); - const variables = { - data: { - email: user.email, - password: 'fake', - profile: { create: { firstName: chance.first(), lastName: chance.last() } }, - }, + const data = { + email: user.email, + password: 'fake', + profile: { firstName: chance.first(), lastName: chance.last() }, }; - const response = await graphQLRequest({ query, variables }); - const errorMessages = response.body.errors.map((e: GraphQLError) => e.message); - - expect(errorMessages).toMatchInlineSnapshot(` - Array [ - "Email already exists.", - ] - `); + await expect(trpcRequest().user.signup(data)).rejects.toThrowErrorMatchingInlineSnapshot( + `"Email already exists."` + ); }); }); describe('trying to pass role', () => { it('throws an error', async () => { - const query = ` - mutation SIGNUP($data: SignupInput!) { - signup(data: $data) { - token - user { - id - } - } - } - `; - - const variables = { - data: { - email: 'hello@wee.net', - password: 'fake', - profile: { create: { firstName: chance.first(), lastName: chance.last() } }, - roles: { set: [Role.ADMIN] }, - }, + const data = { + email: 'hello@wee.net', + password: 'fake', + profile: { firstName: chance.first(), lastName: chance.last() }, + roles: [Role.ADMIN], }; - const response = await graphQLRequest({ query, variables }); - const errors = response.body.errors.map((e: GraphQLError) => e.message); + const { + user: { email, roles }, + } = await trpcRequest().user.signup(data); - expect(errors).toMatchInlineSnapshot(` - Array [ - "Variable \\"$data\\" got invalid value { email: \\"hello@wee.net\\", password: \\"fake\\", profile: { create: [Object] }, roles: { set: [Array] } }; Field \\"roles\\" is not defined by type \\"SignupInput\\".", - ] - `); + expect({ email, roles }).toEqual({ email: 'hello@wee.net', roles: ['USER'] }); }); }); describe('valid signup', () => { it('creates the user', async () => { - const query = ` - mutation SIGNUP($data: SignupInput!) { - signup(data: $data) { - token - user { - id - } - } - } - `; - - // eslint-disable-next-line - const { roles, ...attrs } = UserFactory.build(); + const { roles: _, ...attrs } = UserFactory.build(); - const variables = { - data: { - ...attrs, - profile: { create: { firstName: chance.first(), lastName: chance.last() } }, - }, + const data = { + ...attrs, + profile: { firstName: chance.first(), lastName: chance.last() }, }; - const response = await graphQLRequest({ query, variables }); - const { token, user } = response.body.data.signup; + const response = await trpcRequest().user.signup(data); + const { token, user } = response; expect(token).not.toBeNull(); expect(user.id).not.toBeNull(); diff --git a/packages/create-bison-app/template/tests/requests/user/users.test.ts b/packages/create-bison-app/template/tests/requests/user/users.test.ts index f00cd21a..dee31435 100644 --- a/packages/create-bison-app/template/tests/requests/user/users.test.ts +++ b/packages/create-bison-app/template/tests/requests/user/users.test.ts @@ -1,9 +1,7 @@ import { Role } from '@prisma/client'; -import { GraphQLError } from 'graphql'; -import { graphQLRequest, graphQLRequestAsUser, resetDB, disconnect } from '@/tests/helpers'; +import { resetDB, disconnect, trpcRequest } from '@/tests/helpers'; import { UserFactory } from '@/tests/factories/user'; -import { User, UserWhereUniqueInput } from '@/types'; beforeEach(async () => resetDB()); afterAll(async () => disconnect()); @@ -11,63 +9,28 @@ afterAll(async () => disconnect()); describe('users query', () => { describe('not logged in', () => { it('returns an Unauthorized error', async () => { - const query = ` - query USERS { - users { - email - } - } - `; - - const response = await graphQLRequest({ query }); - const errorMessages = response.body.errors.map((e: GraphQLError) => e.message); - - expect(errorMessages).toMatchInlineSnapshot(` - Array [ - "Not authorized", - ] - `); + await expect(trpcRequest().user.findMany()).rejects.toThrowErrorMatchingInlineSnapshot( + `"UNAUTHORIZED"` + ); }); }); describe('logged in as a user', () => { it('returns an Unauthorized error', async () => { - const query = ` - query USERS { - users { - email - } - } - `; - const user = await UserFactory.create(); - const response = await graphQLRequestAsUser(user, { query }); - const errorMessages = response.body.errors.map((e: GraphQLError) => e.message); - - expect(errorMessages).toMatchInlineSnapshot(` - Array [ - "Not authorized", - ] - `); + await expect(trpcRequest(user).user.findMany()).rejects.toThrowErrorMatchingInlineSnapshot( + `"UNAUTHORIZED"` + ); }); }); describe('logged in as an admin', () => { - it('returns an Unauthorized error', async () => { - const query = ` - query USERS { - users { - email - } - } - `; - + it('returns the user objects', async () => { // create an admin and a regular user const user1 = await UserFactory.create({ roles: { set: [Role.ADMIN] } }); const user2 = await UserFactory.create(); - const response = await graphQLRequestAsUser(user1, { query }); - const { users }: { users: Pick[] } = response.body.data; + const users = await trpcRequest(user1).user.findMany(); const expectedUsers = [user1, user2].map((u) => u.email); const actualUsers = users.map((u) => u.email); @@ -78,22 +41,12 @@ describe('users query', () => { describe('trying to view another users email', () => { it('returns undefined for the email', async () => { - const query = ` - query USER($id: ID!) { - user( where: {id: $id} ) { - id - email - } - } - `; - // create 2 users const user1 = await UserFactory.create(); const user2 = await UserFactory.create(); - const variables: UserWhereUniqueInput = { id: user2.id }; - const response = await graphQLRequestAsUser(user1, { query, variables }); - const { id, email } = response.body.data.user; + const response = await trpcRequest(user1).user.find({ id: user2.id }); + const { id, email } = response; expect(id).not.toBeNull(); expect(email).toBeNull(); @@ -102,22 +55,12 @@ describe('users query', () => { describe('admin trying to view another users email', () => { it('returns the email', async () => { - const query = ` - query USER($id: ID!) { - user(where: { id: $id }) { - id - email - } - } - `; - // create an admin and a regular user const user1 = await UserFactory.create({ roles: { set: [Role.ADMIN] } }); const user2 = await UserFactory.create(); - const variables: UserWhereUniqueInput = { id: user2.id }; - const response = await graphQLRequestAsUser(user1, { query, variables }); - const { id, email } = response.body.data.user; + const response = await trpcRequest(user1).user.find({ id: user2.id }); + const { id, email } = response; expect(id).not.toBeNull(); expect(email).not.toBeNull(); diff --git a/packages/create-bison-app/template/tests/unit/components/SignupForm.test.tsx b/packages/create-bison-app/template/tests/unit/components/SignupForm.test.tsx index 173bd3a5..d31f39c7 100644 --- a/packages/create-bison-app/template/tests/unit/components/SignupForm.test.tsx +++ b/packages/create-bison-app/template/tests/unit/components/SignupForm.test.tsx @@ -46,6 +46,7 @@ describe('SignupForm', () => { await waitFor(() => expect(passwordInput).toHaveAttribute('aria-invalid', 'true')); + userEvent.clear(passwordInput); userEvent.type(passwordInput, 'ihave8characters'); await waitFor(() => expect(passwordInput).not.toHaveAttribute('aria-invalid', 'true')); diff --git a/packages/create-bison-app/template/tests/utils.tsx b/packages/create-bison-app/template/tests/utils.tsx index 2e9d864c..9d65fa56 100644 --- a/packages/create-bison-app/template/tests/utils.tsx +++ b/packages/create-bison-app/template/tests/utils.tsx @@ -1,13 +1,19 @@ import '@testing-library/jest-dom'; import { render as defaultRender } from '@testing-library/react'; -// import { MockedProvider, MockedResponse } from '@apollo/react-testing'; import { RouterContext } from 'next/dist/shared/lib/router-context'; import { NextRouter } from 'next/router'; +import fetch from 'cross-fetch'; +import { createTRPCReact, loggerLink } from '@trpc/react-query'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useState } from 'react'; import '@testing-library/jest-dom/extend-expect'; import { AllProviders } from '@/components/AllProviders'; -// import { ME_QUERY } from '@/context/auth'; -// import { User } from '@/types'; +import type { AppRouter } from '@/server/routers/_app'; + +export const trpc = createTRPCReact(); + +globalThis.fetch = fetch; export * from '@testing-library/react'; export { default as userEvent } from '@testing-library/user-event'; @@ -17,10 +23,22 @@ export { default as userEvent } from '@testing-library/user-event'; */ export function render(ui: RenderUI, { router = {}, ...options }: RenderOptions = {}) { return defaultRender(ui, { - wrapper: ({ children }) => { + wrapper: function Wrapper({ children }) { + const [queryClient] = useState(() => new QueryClient()); + + const [trpcClient] = useState(() => + trpc.createClient({ + links: [loggerLink()], + }) + ); + return ( - {children} + + + {children} + + ); }, @@ -28,47 +46,6 @@ export function render(ui: RenderUI, { router = {}, ...options }: RenderOptions }); } -// const createUserMock = (user?: Partial) => { -// return { -// request: { -// query: ME_QUERY, -// }, -// result: { -// data: { -// me: user || null, -// }, -// }, -// }; -// }; - -/** - * Returns a mock apollo provider, with an optional logged in user for convenience - * @param currentUser if passed, automatically creates a mock for the user - * @param apolloMocks Mocks for Apollo - * @param router Optional router context - * @param children - */ -// export const WithMockedTestState = (props: WithMockedTestStateProps) => { -// const { currentUser, apolloMocks = [], children, router } = props; -// const userMock: MockedResponse = createUserMock(currentUser); -// const mocks = [userMock, ...apolloMocks]; -// const routerProviderValue = { ...mockRouter, ...router } as NextRouter; - -// // addTypename={false} is required if using fragments -// return ( -// -// {children} -// -// ); -// }; - -// interface WithMockedTestStateProps { -// currentUser?: Partial; -// apolloMocks?: MockedResponse[]; -// router?: Partial; -// children?: any; -// } - const mockRouter: NextRouter = { basePath: '', pathname: '/', diff --git a/packages/create-bison-app/template/tsconfig.json b/packages/create-bison-app/template/tsconfig.json index 849bc872..ceab19cd 100644 --- a/packages/create-bison-app/template/tsconfig.json +++ b/packages/create-bison-app/template/tsconfig.json @@ -19,11 +19,6 @@ "isolatedModules": true, "jsx": "preserve", "sourceMap": true, - "plugins": [ - { - "name": "nexus/typescript-language-service" - } - ], "typeRoots": [ "node_modules/@types", "types" @@ -38,7 +33,6 @@ "include": [ ".", "next-env.d.ts", - "types.ts", "types.d.ts", "**/*.ts", "**/*.tsx" diff --git a/packages/create-bison-app/template/utils/setErrors.ts b/packages/create-bison-app/template/utils/setErrors.ts index 9a064883..43a3a2ce 100644 --- a/packages/create-bison-app/template/utils/setErrors.ts +++ b/packages/create-bison-app/template/utils/setErrors.ts @@ -1,24 +1,20 @@ import { UseFormSetError } from 'react-hook-form'; /** - * Sets errors on the frontend from a GraphQL Response. Assumes react-hook-form. + * Sets errors on the frontend from a tRPC Response. Assumes BisonError is used in the backend and react-hook-form. */ -export function setErrorsFromGraphQLErrors( - setError: UseFormSetError, - errors: ErrorResponse[] -) { - return (errors || []).forEach((e) => { - const errorObjects = e.extensions.invalidArgs || {}; - Object.keys(errorObjects).forEach((key) => { - setError(key, { type: 'manual', message: errorObjects[key] }); - }); +export function setErrorsFromTRPCError(setError: UseFormSetError, error: ErrorResponse) { + const errorObjects = error.data.invalidArgs || {}; + Object.keys(errorObjects).forEach((key) => { + setError(key, { type: 'manual', message: errorObjects[key] }); }); } interface ErrorResponse { - extensions: { + data: { code: string; invalidArgs: Record; - message: string; + path: string; }; + message: string; } diff --git a/packages/create-bison-app/test/tasks/copyFiles.test.js b/packages/create-bison-app/test/tasks/copyFiles.test.js index 7592f9fa..6eb3d4ba 100644 --- a/packages/create-bison-app/test/tasks/copyFiles.test.js +++ b/packages/create-bison-app/test/tasks/copyFiles.test.js @@ -78,7 +78,6 @@ describe("copyFiles", () => { ".eslintrc.js", ".hygen.js", ".tool-versions", - "codegen.yml", "constants.ts", "cypress.config.ts", "jest.config.js", @@ -105,7 +104,7 @@ describe("copyFiles", () => { it("copies pages", async () => { const files = [ - "api/graphql.ts", + "api/trpc/[trpc].ts", "_app.tsx", "index.tsx", "login.tsx", @@ -141,24 +140,30 @@ describe("copyFiles", () => { expect(fileString).toContain(name); }); - it("copies graphql", async () => { + it("copies server", async () => { const files = [ - "schema.ts", + "trpc.ts", "context.ts", - "modules/index.ts", - "modules/user.ts", - "modules/scalars.ts", + "middleware/auth.ts", + "middleware/timing.ts", + "routers/_app.ts", + "routers/user.ts", ]; files.forEach((file) => { - const filePath = path.join(targetFolder, "graphql", file); + const filePath = path.join(targetFolder, "server", file); expect(() => fs.statSync(filePath)).not.toThrowError(); }); }); it("copies e2e tests", async () => { - const files = [".eslintrc.json", "login.cy.ts", "logout.cy.ts", "tsconfig.json"]; + const files = [ + ".eslintrc.json", + "login.cy.ts", + "logout.cy.ts", + "tsconfig.json", + ]; files.forEach((file) => { const filePath = path.join(targetFolder, "tests", "e2e", file); @@ -221,7 +226,7 @@ describe("copyFiles", () => { const files = [ "helpers/buildPrismaIncludeFromAttrs.ts", "helpers/db.ts", - "helpers/graphQLRequest.ts", + "helpers/trpcRequest.ts", "helpers/index.ts", "jest.setup.js", "jest.teardown.js", @@ -240,8 +245,9 @@ describe("copyFiles", () => { const files = [ "cell/new/new.ejs", "component/new/new.ejs", - "graphql/new/graphql.ejs", - "graphql/new/injectImport.ejs", + "trpc/new/trpc.ejs", + "trpc/new/injectImport.ejs", + "trpc/new/injectExport.ejs", "page/new/new.ejs", "test/component/component.ejs", "test/factory/factory.ejs",