https://github.com/orta/redwood-object-identification
https://github.com/orta/redwood-object-identification
Last synced: 11 months ago
JSON representation
- Host: GitHub
- URL: https://github.com/orta/redwood-object-identification
- Owner: orta
- License: mit
- Created: 2021-12-11T18:16:09.000Z (over 4 years ago)
- Default Branch: main
- Last Pushed: 2021-12-12T08:39:45.000Z (over 4 years ago)
- Last Synced: 2025-05-06T22:57:58.635Z (about 1 year ago)
- Language: TypeScript
- Homepage:
- Size: 322 KB
- Stars: 13
- Watchers: 3
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
# Redwood Object Identification Pattern Example
The [GraphQL Object Identification Pattern](https://relay.dev/graphql/objectidentification.htm) is a design pattern where you ensure that every object in your GraphQL schema conforms to a single interface:
```graphql
interface Node {
id: ID!
}
```
Which means you can write something like:
```graphql
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](https://relay.dev/docs/guided-tour/reusing-cached-data/) 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:
```graphql
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](https://github.com/redwoodjs/redwood/issues/3873) with inline fragments crash with RedwoodJS' `gql` ATM, [I sent a fix](https://github.com/redwoodjs/redwood/pull/3891).
## Getting Set Up
### 1. SDL + Resolvers
We're going to need some GraphQL SDL and corresponding resolvers
> [`api/src/graphql/objectIdentification.sdl.ts`](./api/src/graphql/objectIdentification.sdl.ts):
```graphql
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`](./api/src/services/users/users.ts`):
```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:
```prisma
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](https://github.com/artsy/README/blob/main/playbooks/graphql-schema-design.md#global-object-identification).
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:
```ts
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:
```diff
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:
```ts
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 `interface`s and not concrete types, which means there are new GraphQL things to handle. We need to have [a way in](https://github.com/graphql/graphql-js/issues/876#issuecomment-304398882) 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:
```ts
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:
```diff
+ 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](./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:
```ts
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 (
Delete
)
}
```
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. :+1: