Coder Social home page Coder Social logo

redwood-object-identification's Introduction

Redwood Object Identification Pattern Example

The GraphQL Object Identification Pattern is a design pattern where you ensure that every object in your GraphQL schema conforms to a single interface:

interface Node {
  id: ID!
}

Which means you can write something like:

type Query {
  node(id: ID): Node! @skipAuth
}

This is cool, because now you have a guaranteed query to be able to get the info for any object in your graph! This feature gives you a bunch of caching super-powers in Relay and probably with Apollo (I don't know their caching strats intimately, but it would make re-fetching any object trivial).

This Repo

That said, for my case this repo currently handles Node in a different place, I wanted to create the anti-node resolver:

type Mutation {
  deleteNode(id: ID): Node! @requireAuth
}

This is useful for the sample because I only need one model to be useful and also because queries with inline fragments crash with RedwoodJS' gql ATM, I sent a fix.

Getting Set Up

1. SDL + Resolvers

We're going to need some GraphQL SDL and corresponding resolvers

api/src/graphql/objectIdentification.sdl.ts:

export const schema = gql`
  scalar ID

  interface Node {
    id: ID!
  }

  type Query {
    node(id: ID): Node! @skipAuth
  }

  type Mutation {
    deleteNode(id: ID): Node! @requireAuth
  }
`

This sets up some new graphql fields, and declares the new primitive ID which is an arbitrary string under the hood.

To understand the ID, let's look at how I implement it in the createUser resolver

./api/src/services/users/users.ts:

import cuid from "cuid"
import { db } from "src/lib/db"

export const createUser = ({ input }: CreateUserArgs) => {
  input.id = cuid() + ":user"
  input.slug = cuid.slug()

  return db.user.create({
    data: input,
  })
}

Prior to setting up for Object Identification, I would have made a prisma schema like:

model User {
  id String @id @default(cuid())
}

This... doesn't really work in the Object Identification era because a cuid is as good UUID, but there's no (safe/simple/easy) way of going from the UUID string back to the original object because it's basically random digits. A route we use at Artsy was to base64 encode that metadata into the id.

Really though?

I had a few ideas for generating thse keys within the framework of letting prisma handle it, starting with making an object-identification query that looks in all potential db tables via a custom query... That's a bit dangerous and then you need to figure out which table you found the object in and then start thinking about that objects access rights. That's tricky.

Another alternative I explored was having prisma generate a dbID via dbID String @id @default(cuid()) then have a postgres function run on a row write to generate an id with the suffix indicating the type. This kinda worked, but was a bit meh answer to me. At that point I gave up on letting prisma handle it at all.

So, I recommend you taking control of generating the id in your app's code by having a totally globally unique id via a cuid + prefix, and then have a slug if you ever need to present it to the user via a URL.

To handle this case, I've been using this for resolving a single item:

export const user = async (args: { id: string }) => {
  // Allow looking up with the same function with either slug or id
  const query = args.id.length > 10 ? { id: args.id } : { slug: args.id }
  const user = await db.user.findUnique({ where: query })

  return user
}

Which allows you to resolve a user with either slug or id.

So instead now it looks like:

model User {
+ id String  @id @unique
- id String @id @default(cuid())
}

2. ID Implementation

Under the hood ID is a real cuid mixed with an identifier prefix which lets you know which model it came from. The simplest implementation would of the node resolver look like this:

import { user } from "./users/users"

export const node = (args: { id: string }) => {
  if (args.id.endsWith(":user")) {
    return user({ id: args.id })
  }

  throw new Error(`Did not find a resolver for node with ${args.id}`)
}

Basically, by looking at the end of the ID we can know which underlying graphql resolver we should forward the request to, this means no duplication of access control inside the node function - it just forwards to the other existing GraphQL resolvers.

3. Disambiguation

The next thing you would hit is kind of only something you hit when you try this in practice. We're now writing to interfaces and not concrete types, which means there are new GraphQL things to handle. We need to have a way in the GraphQL server to go from an interface (or union) to the concrete type.

That is done by one of two methods, depending on your needs:

  • A single function on the interface which can disambiguate the types ( Node.resolveType )
  • Or each concrete type can have a way to declare if the JS object / ID is one of it's own GraphQL type ( User.isTypeOf (and for every other model) )

Now, today (as of RedwoodJS v1.0rc), doing either of these things isn't possible via the normal RedwoodJS APIs, it's complicated but roughly the *.sdl.ts files only let you create resolvers and not manipulate the schema objects in your app. So, we'll write a quick envelop plugin do handle that for us:

export const createNodeResolveEnvelopPlugin = (): Plugin => {
  return {
    onSchemaChange({ schema }) {
      const node: { resolveType?: (obj: { id: string }) => string } = schema.getType("Node") as unknown
      node.resolveType = (obj) => {
        if (obj.id.endsWith(":user")) {
          return "User"
        }

        throw new Error(`Did not find a resolver for deleteNode with ${args.id}`)
      }
    }
  }
}

And then add that to the graphql function:

+ import { createNodeResolveEnvelopPlugin } from "src/services/objectIdentification"

export const handler = createGraphQLHandler({
  loggerConfig: { logger, options: {} },
  directives,
  sdls,
  services,
+  extraPlugins: [createNodeResolveEnvelopPlugin()],
  onException: () => {
    // Disconnect from your database with an unhandled exception.
    db.$disconnect()
  },
})

The real implementation in this app is a little more abstract `/api/src/services/objectIdentification.ts but it does the work well.

4. Usage

Finally, an actual outcome, you can see the new DeleteButton which I added in this repo using the deleteNode resolver which has a lot of similar patterns as the node resolver under the hood:

import { navigate, routes } from "@redwoodjs/router"
import { useMutation } from "@redwoodjs/web"
import { toast } from "@redwoodjs/web/dist/toast"

const DELETE_NODE_MUTATION = gql`
  mutation DeleteNodeMutation($id: ID!) {
    deleteNode(id: $id) {
      id
    }
  }
`

export const DeleteButton = (props: { id: string; displayName: string }) => {
  const [deleteUser] = useMutation(DELETE_NODE_MUTATION, {
    onCompleted: () => {
      toast.success(`${props.displayName} deleted`)
      navigate(routes.users())
    },
    onError: (error) => {
      toast.error(error.message)
    },
  })

  const onDeleteClick = () => {
    if (confirm(`Are you sure you want to delete ${props.displayName}?`)) {
      deleteUser({ variables: { id: props.id } })
    }
  }
  return (
    <button type="button" title={`Delete ${props.displayName}`} className="rw-button rw-button-small rw-button-red" onClick={onDeleteClick}>
      Delete
    </button>
  )
}

It can delete any object which conforms to the Node protocol in your app, making it DRY and type-safe - and because it also forwards to each model's "delete node" resolver then it also gets all of the access control right checks in those functions too. ๐Ÿ‘

Recommend Projects

  • React photo React

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

  • Vue.js photo Vue.js

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

  • Typescript photo Typescript

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

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

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

  • web

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

  • server

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

  • Machine learning

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

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

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

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.