Skip to content

Commit

Permalink
feat(graffle): extension system (#871)
Browse files Browse the repository at this point in the history
  • Loading branch information
jasonkuhrt authored May 23, 2024
1 parent 9d81d14 commit 6eebe6f
Show file tree
Hide file tree
Showing 31 changed files with 1,821 additions and 287 deletions.
30 changes: 23 additions & 7 deletions DOCUMENTATION_NEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,25 +16,41 @@ You can change the output of client methods by configuring its return mode. This

The only client method that is not affected by return mode is `raw` which will _always_ return a standard GraphQL result type.

Here is a summary table of the modes:

| Mode | Throw Sources (no type safety) | Returns (type safe) |
| ---------------- | ------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| `graphql` | Extensions, Fetch | `GraphQLExecutionResult` |
| `graphqlSuccess` | Extensions, Fetch, GraphQLExecutionResult.errors | `GraphQLExecutionResult` with `.errors` always missing. |
| `data` (default) | Extensions, Fetch, GraphQLExecutionResult.errors | `GraphQLExecutionResult.data` |
| `dataSuccess` | Extensions, Fetch, GraphQLExecutionResult.errors, GraphQLExecutionResult.data Schema Errors | `GraphQLExecutionResult.data` without any schema errors |
| `dataAndErrors` | | `GraphQLExecutionResult.data`, errors from: Extensions, Fetch, GraphQLExecutionResult.errors |

## `graphql`

Return the standard graphql execution output.

## `data`

Return just the data including [schema errors](#schema-errors) (if using). Other errors are thrown as an `AggregateError`.
## `graphqlSuccess`

**This mode is the default.**
Return the standard graphql execution output. However, if there would be any errors then they're thrown as an `AggregateError`.
This mode acts like you were using [`OrThrow`](#orthrow) method variants all the time.

## `successData`
## `dataSuccess`

Return just the data excluding [schema errors](#schema-errors). Errors are thrown as an `AggregateError`. This mode acts like you were using [`OrThrow`](#orthrow) method variants all the time.
Return just the data excluding [schema errors](#schema-errors). Errors are thrown as an `AggregateError`.
This mode acts like you were using [`OrThrow`](#orthrow) method variants all the time.

This mode is only available when using [schema errors](#schema-errors).

## `data`

Return just the data including [schema errors](#schema-errors) (if using). Other errors are thrown as an `AggregateError`.

**This mode is the default.**

## `dataAndErrors`

Return data and errors. This is the most type-safe mode. It never throws.
Return a union type of data and errors. This is the most type-safe mode. It never throws.

# Schema Errors

Expand Down
3 changes: 3 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,7 @@ export default tsEslint.config({
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
['@typescript-eslint/only-throw-error']: 'off',
},
})
2 changes: 1 addition & 1 deletion src/layers/0_functions/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ interface Input extends BaseInput {
schema: GraphQLSchema
}

export const execute = async (input: Input): Promise<ExecutionResult<any>> => {
export const execute = async (input: Input): Promise<ExecutionResult> => {
switch (typeof input.document) {
case `string`: {
return await graphql({
Expand Down
6 changes: 4 additions & 2 deletions src/layers/0_functions/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,17 @@ import type { BaseInput } from './types.js'

export type URLInput = URL | string

interface Input extends BaseInput {
export interface NetworkRequestInput extends BaseInput {
url: URLInput
headers?: HeadersInit
}

export type NetworkRequest = (input: NetworkRequestInput) => Promise<ExecutionResult>

/**
* @see https://graphql.github.io/graphql-over-http/draft/
*/
export const request = async (input: Input): Promise<ExecutionResult> => {
export const request: NetworkRequest = async (input) => {
const documentEncoded = typeof input.document === `string` ? input.document : print(input.document)

const body = {
Expand Down
23 changes: 0 additions & 23 deletions src/layers/0_functions/requestOrExecute.ts
Original file line number Diff line number Diff line change
@@ -1,23 +0,0 @@
import type { ExecutionResult, GraphQLSchema } from 'graphql'
import { execute } from './execute.js'
import type { URLInput } from './request.js'
import { request } from './request.js'
import type { BaseInput } from './types.js'

export type SchemaInput = URLInput | GraphQLSchema

export interface Input extends BaseInput {
schema: SchemaInput
}

export const requestOrExecute = async (
input: Input,
): Promise<ExecutionResult> => {
const { schema, ...baseInput } = input

if (schema instanceof URL || typeof schema === `string`) {
return await request({ url: schema, ...baseInput })
}

return await execute({ schema, ...baseInput })
}
23 changes: 18 additions & 5 deletions src/layers/2_generator/globalRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,35 @@ import type { Schema } from '../1_Schema/__.js'

declare global {
export namespace GraphQLRequestTypes {
interface Schemas {
}
interface Schemas {}
// Use this is for manual internal type testing.
// interface SchemasAlwaysEmpty {}
}
}

export type GlobalRegistry = Record<string, {
type SomeSchema = {
index: Schema.Index
customScalars: Record<string, Schema.Scalar.Scalar>
featureOptions: {
schemaErrors: boolean
}
}>
}

type ZeroSchema = {
index: { name: never }
featureOptions: {
schemaErrors: false
}
}

export type GlobalRegistry = Record<string, SomeSchema>

export namespace GlobalRegistry {
export type Schemas = GraphQLRequestTypes.Schemas
export type SchemaList = Values<Schemas>

export type IsEmpty = keyof Schemas extends never ? true : false

export type SchemaList = IsEmpty extends true ? ZeroSchema : Values<Schemas>

export type DefaultSchemaName = 'default'

Expand Down
8 changes: 4 additions & 4 deletions src/layers/3_SelectionSet/encode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,12 @@ export interface Context {

export const rootTypeSelectionSet = (
context: Context,
schemaObject: Schema.Object$2,
ss: GraphQLObjectSelection,
objectDef: Schema.Object$2,
selectionSet: GraphQLObjectSelection,
operationName: string = ``,
) => {
const operationTypeName = lowerCaseFirstLetter(schemaObject.fields.__typename.type.type)
return `${operationTypeName} ${operationName} { ${resolveObjectLikeFieldValue(context, schemaObject, ss)} }`
const operationTypeName = lowerCaseFirstLetter(objectDef.fields.__typename.type.type)
return `${operationTypeName} ${operationName} { ${resolveObjectLikeFieldValue(context, objectDef, selectionSet)} }`
}

const resolveDirectives = (fieldValue: FieldValue) => {
Expand Down
10 changes: 9 additions & 1 deletion src/layers/5_client/Config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,26 @@ import type { SelectionSet } from '../3_SelectionSet/__.js'

export type ReturnModeType =
| ReturnModeTypeGraphQL
| ReturnModeTypeGraphQLSuccess
| ReturnModeTypeSuccessData
| ReturnModeTypeData
| ReturnModeTypeDataAndErrors

export type ReturnModeTypeBase = ReturnModeTypeGraphQL | ReturnModeTypeDataAndErrors | ReturnModeTypeData
export type ReturnModeTypeBase =
| ReturnModeTypeGraphQLSuccess
| ReturnModeTypeGraphQL
| ReturnModeTypeDataAndErrors
| ReturnModeTypeData

export type ReturnModeTypeGraphQLSuccess = 'graphqlSuccess'

export type ReturnModeTypeGraphQL = 'graphql'

export type ReturnModeTypeData = 'data'

export type ReturnModeTypeDataAndErrors = 'dataAndErrors'

// todo rename to dataSuccess
export type ReturnModeTypeSuccessData = 'successData'

export type OptionsInput = {
Expand Down
4 changes: 2 additions & 2 deletions src/layers/5_client/RootTypeMethods.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { OperationName } from '../../lib/graphql.js'
import type { OperationTypeName } from '../../lib/graphql.js'
import type { Exact } from '../../lib/prelude.js'
import type { TSError } from '../../lib/TSError.js'
import type { InputFieldsAllNullable, Schema } from '../1_Schema/__.js'
Expand All @@ -23,7 +23,7 @@ type RootTypeFieldContext = {

// dprint-ignore
export type GetRootTypeMethods<$Config extends Config, $Index extends Schema.Index> = {
[$OperationName in OperationName as $Index['Root'][Capitalize<$OperationName>] extends null ? never : $OperationName]:
[$OperationName in OperationTypeName as $Index['Root'][Capitalize<$OperationName>] extends null ? never : $OperationName]:
RootTypeMethods<$Config, $Index, Capitalize<$OperationName>>
}

Expand Down
24 changes: 24 additions & 0 deletions src/layers/5_client/client.batch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, test } from 'vitest'
import { db } from '../../../tests/_/db.js'
import { Graffle } from '../../../tests/_/schema/generated/__.js'
import * as Schema from '../../../tests/_/schema/schema.js'

const graffle = Graffle.create({ schema: Schema.schema })

// dprint-ignore
describe(`query`, () => {
test(`success`, async () => {
await expect(graffle.query.$batch({ id: true })).resolves.toMatchObject({ id:db.id })
})
test(`error`, async () => {
await expect(graffle.query.$batch({ error: true })).rejects.toMatchObject(db.errorAggregate)
})
describe(`orThrow`, () => {
test(`success`, async () => {
await expect(graffle.query.$batchOrThrow({ id: true })).resolves.toMatchObject({ id:db.id })
})
test(`error`, async () => {
await expect(graffle.query.$batchOrThrow({ error: true })).rejects.toMatchObject(db.errorAggregate)
})
})
})
4 changes: 2 additions & 2 deletions src/layers/5_client/client.document.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ describe(`document with two queries`, () => {
// @ts-expect-error
await expect(run(`boo`)).rejects.toMatchObject({ errors: [{ message: `Unknown operation named "boo".` }] })
})
test(`error if invalid name in document`, async () => {
test.skip(`error if invalid name in document`, async () => {
// @ts-expect-error
const { run } = graffle.document({ foo$: { query: { id: true } } })
await expect(run(`foo$`)).rejects.toMatchObject({
Expand Down Expand Up @@ -86,7 +86,7 @@ describe(`document(...).runOrThrow()`, () => {
`[Error: Failure on field resultNonNull: ErrorOne]`,
)
})
test(`multiple via alias`, async () => {
test.todo(`multiple via alias`, async () => {
const result = graffle.document({
x: { query: { resultNonNull: { $: { case: `ErrorOne` } }, resultNonNull_as_x: { $: { case: `ErrorOne` } } } },
}).runOrThrow()
Expand Down
40 changes: 40 additions & 0 deletions src/layers/5_client/client.extend.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/* eslint-disable */
import { ExecutionResult } from 'graphql'
import { describe, expect } from 'vitest'
import { db } from '../../../tests/_/db.js'
import { createResponse, test } from '../../../tests/_/helpers.js'
import { Graffle } from '../../../tests/_/schema/generated/__.js'
import { GraphQLExecutionResult } from '../../legacy/lib/graphql.js'

const client = Graffle.create({ schema: 'https://foo', returnMode: 'dataAndErrors' })
const headers = { 'x-foo': 'bar' }

// todo each extension added should copy, not mutate the client

describe(`entrypoint request`, () => {
test(`can add header to request`, async ({ fetch }) => {
fetch.mockImplementationOnce(async (input: Request) => {
expect(input.headers.get('x-foo')).toEqual(headers['x-foo'])
return createResponse({ data: { id: db.id } })
})
const client2 = client.extend(async ({ pack }) => {
// todo should be raw input types but rather resolved
// todo should be URL instance?
// todo these input type tests should be moved down to Anyware
// expectTypeOf(exchange).toEqualTypeOf<NetworkRequestHook>()
// expect(exchange.input).toEqual({ url: 'https://foo', document: `query { id \n }` })
return await pack({ ...pack.input, headers })
})
expect(await client2.query.id()).toEqual(db.id)
})
test('can chain into exchange', async ({ fetch }) => {
fetch.mockImplementationOnce(async () => {
return createResponse({ data: { id: db.id } })
})
const client2 = client.extend(async ({ pack }) => {
const { exchange } = await pack({ ...pack.input, headers })
return await exchange(exchange.input)
})
expect(await client2.query.id()).toEqual(db.id)
})
})
18 changes: 1 addition & 17 deletions src/layers/5_client/client.rootTypeMethods.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,26 +38,10 @@ describe(`query`, () => {
})
describe(`orThrow`, () => {
test(`without error`, async () => {
await expect(graffle.query.objectWithArgsOrThrow({ $: { id: `x` }, id: true })).resolves.toEqual({ id: `x` })
await expect(graffle.query.objectWithArgsOrThrow({ $: { id: `x` }, id: true })).resolves.toEqual({ id: `x`, __typename: `Object1` })
})
test(`with error`, async () => {
await expect(graffle.query.errorOrThrow()).rejects.toMatchObject(db.errorAggregate)
})
})
describe(`$batch`, () => {
test(`success`, async () => {
await expect(graffle.query.$batch({ id: true })).resolves.toMatchObject({ id:db.id })
})
test(`error`, async () => {
await expect(graffle.query.$batch({ error: true })).rejects.toMatchObject(db.errorAggregate)
})
describe(`orThrow`, () => {
test(`success`, async () => {
await expect(graffle.query.$batchOrThrow({ id: true })).resolves.toMatchObject({ id:db.id })
})
test(`error`, async () => {
await expect(graffle.query.$batchOrThrow({ error: true })).rejects.toMatchObject(db.errorAggregate)
})
})
})
})
Loading

0 comments on commit 6eebe6f

Please sign in to comment.