Skip to content

Commit

Permalink
feat: Support GraphQL Fragments with Apollo Client and Fragment Regis…
Browse files Browse the repository at this point in the history
…try (#9140)

This PR adds support to register GraphQL Fragments.

It:

* adds a fragmentRegistry to the Apollo client cache
* Sets up graphql config to recognize fragments in VSCode
* Generates possible types for Apollo client union type and interface
support
* Exposes a useRegisteredFragment to render cached data
* New test app with fragments for future CI
* Adds possible types to create redwood app templates
* Document the possible types code gen
* Documents use of fragments and the fragment registry helpers

This is a WIP and first phase for easier fragment support.

See the text fixture for some examples and the docs for usage.

Still to code are use in mutations and updating the fragment in the
client cache directly.
  • Loading branch information
dthyresson authored and jtoar committed Oct 8, 2023
1 parent 20a1fd1 commit 36ffcb4
Show file tree
Hide file tree
Showing 74 changed files with 2,215 additions and 19 deletions.
102 changes: 102 additions & 0 deletions __fixtures__/fragment-test-project/.redwood/schema.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"""
Use to check whether or not a user is authenticated and is associated
with an optional set of roles.
"""
directive @requireAuth(roles: [String]) on FIELD_DEFINITION

"""Use to skip authentication checks and allow public access."""
directive @skipAuth on FIELD_DEFINITION

scalar BigInt

scalar Date

scalar DateTime

type Fruit implements Grocery {
id: ID!

"""Seedless is only for fruits"""
isSeedless: Boolean
name: String!
nutrients: String
price: Int!
quantity: Int!
region: String!

"""Ripeness is only for fruits"""
ripenessIndicators: String
stall: Stall!
}

union Groceries = Fruit | Vegetable

interface Grocery {
id: ID!
name: String!
nutrients: String
price: Int!
quantity: Int!
region: String!
stall: Stall!
}

scalar JSON

scalar JSONObject

"""About the Redwood queries."""
type Query {
fruitById(id: ID!): Fruit
fruits: [Fruit!]!
groceries: [Groceries!]!

"""Fetches the Redwood root schema."""
redwood: Redwood
stallById(id: ID!): Stall
stalls: [Stall!]!
vegetableById(id: ID!): Vegetable
vegetables: [Vegetable!]!
}

"""
The RedwoodJS Root Schema
Defines details about RedwoodJS such as the current user and version information.
"""
type Redwood {
"""The current user."""
currentUser: JSON

"""The version of Prisma."""
prismaVersion: String

"""The version of Redwood."""
version: String
}

type Stall {
fruits: [Fruit]
id: ID!
name: String!
stallNumber: String!
vegetables: [Vegetable]
}

scalar Time

type Vegetable implements Grocery {
id: ID!

"""Pickled is only for vegetables"""
isPickled: Boolean
name: String!
nutrients: String
price: Int!
quantity: Int!
region: String!
stall: Stall!

"""Veggie Family is only for vegetables"""
vegetableFamily: String
}
4 changes: 4 additions & 0 deletions __fixtures__/fragment-test-project/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Project to Test GraphQL Fragments

* Has unions and interfaces to test: https://www.apollographql.com/docs/react/data/fragments/#using-fragments-with-unions-and-interfaces
* Generates "possible types" for Apollo Client, see: https://www.apollographql.com/docs/react/data/fragments/#defining-possibletypes-manually
36 changes: 36 additions & 0 deletions __fixtures__/fragment-test-project/api/db/schema.prisma
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
directUrl = env("DIRECT_URL")
}

generator client {
provider = "prisma-client-js"
binaryTargets = "native"
}

model Stall {
id String @id @default(cuid())
name String
stallNumber String @unique
produce Produce[]
}

model Produce {
id String @id @default(cuid())
name String @unique
quantity Int
price Int
nutrients String?
region String
/// Available only for fruits
isSeedless Boolean?
/// Available only for fruits
ripenessIndicators String?
/// Available only for vegetables
vegetableFamily String?
/// Available only for vegetables
isPickled Boolean?
stall Stall @relation(fields: [stallId], references: [id], onDelete: Cascade)
stallId String
}
8 changes: 8 additions & 0 deletions __fixtures__/fragment-test-project/api/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// More info at https://redwoodjs.com/docs/project-configuration-dev-test-build

const config = {
rootDir: '../',
preset: '@redwoodjs/testing/config/jest/api',
}

module.exports = config
9 changes: 9 additions & 0 deletions __fixtures__/fragment-test-project/api/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "api",
"version": "0.0.0",
"private": true,
"dependencies": {
"@redwoodjs/api": "6.2.0",
"@redwoodjs/graphql-server": "6.2.0"
}
}
52 changes: 52 additions & 0 deletions __fixtures__/fragment-test-project/api/server.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* This file allows you to configure the Fastify Server settings
* used by the RedwoodJS dev server.
*
* It also applies when running RedwoodJS with `yarn rw serve`.
*
* For the Fastify server options that you can set, see:
* https://www.fastify.io/docs/latest/Reference/Server/#factory
*
* Examples include: logger settings, timeouts, maximum payload limits, and more.
*
* Note: This configuration does not apply in a serverless deploy.
*/

/** @type {import('fastify').FastifyServerOptions} */
const config = {
requestTimeout: 15_000,
logger: {
// Note: If running locally using `yarn rw serve` you may want to adjust
// the default non-development level to `info`
level: process.env.NODE_ENV === 'development' ? 'debug' : 'warn',
},
}

/**
* You can also register Fastify plugins and additional routes for the API and Web sides
* in the configureFastify function.
*
* This function has access to the Fastify instance and options, such as the side
* (web, api, or proxy) that is being configured and other settings like the apiRootPath
* of the functions endpoint.
*
* Note: This configuration does not apply in a serverless deploy.
*/

/** @type {import('@redwoodjs/api-server/dist/types').FastifySideConfigFn} */
const configureFastify = async (fastify, options) => {
if (options.side === 'api') {
fastify.log.trace({ custom: { options } }, 'Configuring api side')
}

if (options.side === 'web') {
fastify.log.trace({ custom: { options } }, 'Configuring web side')
}

return fastify
}

module.exports = {
config,
configureFastify,
}
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { mockRedwoodDirective, getDirectiveName } from '@redwoodjs/testing/api'

import requireAuth from './requireAuth'

describe('requireAuth directive', () => {
it('declares the directive sdl as schema, with the correct name', () => {
expect(requireAuth.schema).toBeTruthy()
expect(getDirectiveName(requireAuth.schema)).toBe('requireAuth')
})

it('requireAuth has stub implementation. Should not throw when current user', () => {
// If you want to set values in context, pass it through e.g.
// mockRedwoodDirective(requireAuth, { context: { currentUser: { id: 1, name: 'Lebron McGretzky' } }})
const mockExecution = mockRedwoodDirective(requireAuth, { context: {} })

expect(mockExecution).not.toThrowError()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import gql from 'graphql-tag'

import type { ValidatorDirectiveFunc } from '@redwoodjs/graphql-server'
import { createValidatorDirective } from '@redwoodjs/graphql-server'

import { requireAuth as applicationRequireAuth } from 'src/lib/auth'

export const schema = gql`
"""
Use to check whether or not a user is authenticated and is associated
with an optional set of roles.
"""
directive @requireAuth(roles: [String]) on FIELD_DEFINITION
`

type RequireAuthValidate = ValidatorDirectiveFunc<{ roles?: string[] }>

const validate: RequireAuthValidate = ({ directiveArgs }) => {
const { roles } = directiveArgs
applicationRequireAuth({ roles })
}

const requireAuth = createValidatorDirective(schema, validate)

export default requireAuth
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { getDirectiveName } from '@redwoodjs/testing/api'

import skipAuth from './skipAuth'

describe('skipAuth directive', () => {
it('declares the directive sdl as schema, with the correct name', () => {
expect(skipAuth.schema).toBeTruthy()
expect(getDirectiveName(skipAuth.schema)).toBe('skipAuth')
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import gql from 'graphql-tag'

import { createValidatorDirective } from '@redwoodjs/graphql-server'

export const schema = gql`
"""
Use to skip authentication checks and allow public access.
"""
directive @skipAuth on FIELD_DEFINITION
`

const skipAuth = createValidatorDirective(schema, () => {
return
})

export default skipAuth
19 changes: 19 additions & 0 deletions __fixtures__/fragment-test-project/api/src/functions/graphql.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { createGraphQLHandler } from '@redwoodjs/graphql-server'

import directives from 'src/directives/**/*.{js,ts}'
import sdls from 'src/graphql/**/*.sdl.{js,ts}'
import services from 'src/services/**/*.{js,ts}'

import { db } from 'src/lib/db'
import { logger } from 'src/lib/logger'

export const handler = createGraphQLHandler({
loggerConfig: { logger, options: {} },
directives,
sdls,
services,
onException: () => {
// Disconnect from your database with an unhandled exception.
db.$disconnect()
},
})
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
export const schema = gql`
interface Grocery {
id: ID!
name: String!
quantity: Int!
price: Int!
nutrients: String
stall: Stall!
region: String!
}
type Fruit implements Grocery {
id: ID!
name: String!
quantity: Int!
price: Int!
nutrients: String
stall: Stall!
region: String!
"Seedless is only for fruits"
isSeedless: Boolean
"Ripeness is only for fruits"
ripenessIndicators: String
}
type Vegetable implements Grocery {
id: ID!
name: String!
quantity: Int!
price: Int!
nutrients: String
stall: Stall!
region: String!
"Veggie Family is only for vegetables"
vegetableFamily: String
"Pickled is only for vegetables"
isPickled: Boolean
}
union Groceries = Fruit | Vegetable
type Query {
groceries: [Groceries!]! @skipAuth
fruits: [Fruit!]! @skipAuth
fruitById(id: ID!): Fruit @skipAuth
vegetables: [Vegetable!]! @skipAuth
vegetableById(id: ID!): Vegetable @skipAuth
}
`
14 changes: 14 additions & 0 deletions __fixtures__/fragment-test-project/api/src/graphql/stalls.sdl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export const schema = gql`
type Stall {
id: ID!
stallNumber: String!
name: String!
fruits: [Fruit]
vegetables: [Vegetable]
}
type Query {
stalls: [Stall!]! @skipAuth
stallById(id: ID!): Stall @skipAuth
}
`
Loading

0 comments on commit 36ffcb4

Please sign in to comment.