diff --git a/eslint.config.js b/eslint.config.js index 9151142b9..84fe016d6 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -6,7 +6,7 @@ export default tsEslint.config({ 'eslint.config.js', 'vite.config.ts', '**/generated/**/*', - '**/$generated-clients/**/*', + '**/$/**/*', 'legacy/**/*', 'build/**/*', 'website/**/*', diff --git a/examples/$/generated-clients/Pokemon/Client.ts b/examples/$/generated-clients/Pokemon/Client.ts new file mode 100644 index 000000000..ceeef8124 --- /dev/null +++ b/examples/$/generated-clients/Pokemon/Client.ts @@ -0,0 +1,5 @@ +import { createPrefilled } from '../../../../src/entrypoints/client.js' + +import { $defaultSchemaUrl, $Index } from './SchemaRuntime.js' + +export const create = createPrefilled(`Pokemon`, $Index, $defaultSchemaUrl) diff --git a/examples/$generated-clients/SocialStudies/Error.ts b/examples/$/generated-clients/Pokemon/Error.ts similarity index 100% rename from examples/$generated-clients/SocialStudies/Error.ts rename to examples/$/generated-clients/Pokemon/Error.ts diff --git a/examples/$/generated-clients/Pokemon/Global.ts b/examples/$/generated-clients/Pokemon/Global.ts new file mode 100644 index 000000000..cca3730f0 --- /dev/null +++ b/examples/$/generated-clients/Pokemon/Global.ts @@ -0,0 +1,17 @@ +import type { Index } from './Index.js' + +declare global { + export namespace GraffleGlobalTypes { + export interface Schemas { + Pokemon: { + name: 'Pokemon' + index: Index + customScalars: {} + featureOptions: { + schemaErrors: true + } + defaultSchemaUrl: null + } + } + } +} diff --git a/examples/$/generated-clients/Pokemon/Index.ts b/examples/$/generated-clients/Pokemon/Index.ts new file mode 100644 index 000000000..0c7e25f21 --- /dev/null +++ b/examples/$/generated-clients/Pokemon/Index.ts @@ -0,0 +1,27 @@ +/* eslint-disable */ + +import type * as Schema from './SchemaBuildtime.js' + +export interface Index { + name: 'Pokemon' + Root: { + Query: Schema.Root.Query + Mutation: Schema.Root.Mutation + Subscription: null + } + objects: { + Pokemon: Schema.Object.Pokemon + Trainer: Schema.Object.Trainer + } + unions: {} + interfaces: {} + error: { + objects: {} + objectsTypename: {} + rootResultFields: { + Query: {} + Mutation: {} + Subscription: {} + } + } +} diff --git a/examples/$/generated-clients/Pokemon/Scalar.ts b/examples/$/generated-clients/Pokemon/Scalar.ts new file mode 100644 index 000000000..f7aca14c1 --- /dev/null +++ b/examples/$/generated-clients/Pokemon/Scalar.ts @@ -0,0 +1 @@ +export * from '../../../../src/entrypoints/scalars.js' diff --git a/examples/$/generated-clients/Pokemon/SchemaBuildtime.ts b/examples/$/generated-clients/Pokemon/SchemaBuildtime.ts new file mode 100644 index 000000000..2c6b22d79 --- /dev/null +++ b/examples/$/generated-clients/Pokemon/SchemaBuildtime.ts @@ -0,0 +1,90 @@ +import type * as $ from '../../../../src/entrypoints/schema.js' +import type * as $Scalar from './Scalar.ts' + +// ------------------------------------------------------------ // +// Root // +// ------------------------------------------------------------ // + +export namespace Root { + export type Mutation = $.Object$2<'Mutation', { + addPokemon: $.Field< + $.Output.Nullable, + $.Args<{ + attack: $Scalar.Int + defense: $Scalar.Int + hp: $Scalar.Int + name: $Scalar.String + }> + > + }> + + export type Query = $.Object$2<'Query', { + pokemon: $.Field<$.Output.Nullable<$.Output.List>, null> + pokemonByName: $.Field< + $.Output.Nullable<$.Output.List>, + $.Args<{ + name: $Scalar.String + }> + > + trainerByName: $.Field< + $.Output.Nullable, + $.Args<{ + name: $Scalar.String + }> + > + trainers: $.Field<$.Output.Nullable<$.Output.List>, null> + }> +} + +// ------------------------------------------------------------ // +// Enum // +// ------------------------------------------------------------ // + +export namespace Enum { + // -- no types -- +} + +// ------------------------------------------------------------ // +// InputObject // +// ------------------------------------------------------------ // + +export namespace InputObject { + // -- no types -- +} + +// ------------------------------------------------------------ // +// Interface // +// ------------------------------------------------------------ // + +export namespace Interface { + // -- no types -- +} + +// ------------------------------------------------------------ // +// Object // +// ------------------------------------------------------------ // + +export namespace Object { + export type Pokemon = $.Object$2<'Pokemon', { + attack: $.Field<$.Output.Nullable<$Scalar.Int>, null> + defense: $.Field<$.Output.Nullable<$Scalar.Int>, null> + hp: $.Field<$.Output.Nullable<$Scalar.Int>, null> + id: $.Field<$.Output.Nullable<$Scalar.Int>, null> + name: $.Field<$.Output.Nullable<$Scalar.String>, null> + trainer: $.Field<$.Output.Nullable, null> + }> + + export type Trainer = $.Object$2<'Trainer', { + id: $.Field<$.Output.Nullable<$Scalar.Int>, null> + name: $.Field<$.Output.Nullable<$Scalar.String>, null> + pokemon: $.Field<$.Output.Nullable<$.Output.List>, null> + }> +} + +// ------------------------------------------------------------ // +// Union // +// ------------------------------------------------------------ // + +export namespace Union { + // -- no types -- +} diff --git a/examples/$/generated-clients/Pokemon/SchemaRuntime.ts b/examples/$/generated-clients/Pokemon/SchemaRuntime.ts new file mode 100644 index 000000000..631d4d06d --- /dev/null +++ b/examples/$/generated-clients/Pokemon/SchemaRuntime.ts @@ -0,0 +1,70 @@ +/* eslint-disable */ + +import * as $ from '../../../../src/entrypoints/schema.js' +import * as $Scalar from './Scalar.js' + +export const $defaultSchemaUrl = undefined + +// @ts-ignore - circular types cannot infer. Ignore in case there are any. This comment is always added, it does not indicate if this particular type could infer or not. +export const Pokemon = $.Object$(`Pokemon`, { + attack: $.field($.Output.Nullable($Scalar.Int)), + defense: $.field($.Output.Nullable($Scalar.Int)), + hp: $.field($.Output.Nullable($Scalar.Int)), + id: $.field($.Output.Nullable($Scalar.Int)), + name: $.field($.Output.Nullable($Scalar.String)), + // @ts-ignore - circular types cannot infer. Ignore in case there are any. This comment is always added, it does not indicate if this particular type could infer or not. + trainer: $.field($.Output.Nullable(() => Trainer)), +}) + +// @ts-ignore - circular types cannot infer. Ignore in case there are any. This comment is always added, it does not indicate if this particular type could infer or not. +export const Trainer = $.Object$(`Trainer`, { + id: $.field($.Output.Nullable($Scalar.Int)), + name: $.field($.Output.Nullable($Scalar.String)), + // @ts-ignore - circular types cannot infer. Ignore in case there are any. This comment is always added, it does not indicate if this particular type could infer or not. + pokemon: $.field($.Output.Nullable($.Output.List(() => Pokemon))), +}) + +// @ts-ignore - circular types cannot infer. Ignore in case there are any. This comment is always added, it does not indicate if this particular type could infer or not. +export const Mutation = $.Object$(`Mutation`, { + // @ts-ignore - circular types cannot infer. Ignore in case there are any. This comment is always added, it does not indicate if this particular type could infer or not. + addPokemon: $.field( + $.Output.Nullable(() => Pokemon), + $.Args({ attack: $Scalar.Int, defense: $Scalar.Int, hp: $Scalar.Int, name: $Scalar.String }), + ), +}) + +// @ts-ignore - circular types cannot infer. Ignore in case there are any. This comment is always added, it does not indicate if this particular type could infer or not. +export const Query = $.Object$(`Query`, { + // @ts-ignore - circular types cannot infer. Ignore in case there are any. This comment is always added, it does not indicate if this particular type could infer or not. + pokemon: $.field($.Output.Nullable($.Output.List(() => Pokemon))), + // @ts-ignore - circular types cannot infer. Ignore in case there are any. This comment is always added, it does not indicate if this particular type could infer or not. + pokemonByName: $.field($.Output.Nullable($.Output.List(() => Pokemon)), $.Args({ name: $Scalar.String })), + // @ts-ignore - circular types cannot infer. Ignore in case there are any. This comment is always added, it does not indicate if this particular type could infer or not. + trainerByName: $.field($.Output.Nullable(() => Trainer), $.Args({ name: $Scalar.String })), + // @ts-ignore - circular types cannot infer. Ignore in case there are any. This comment is always added, it does not indicate if this particular type could infer or not. + trainers: $.field($.Output.Nullable($.Output.List(() => Trainer))), +}) + +export const $Index = { + name: 'Pokemon' as const, + Root: { + Query, + Mutation, + Subscription: null, + }, + objects: { + Pokemon, + Trainer, + }, + unions: {}, + interfaces: {}, + error: { + objects: {}, + objectsTypename: {}, + rootResultFields: { + Query: {}, + Mutation: {}, + Subscription: {}, + }, + }, +} diff --git a/examples/$/generated-clients/Pokemon/Select.ts b/examples/$/generated-clients/Pokemon/Select.ts new file mode 100644 index 000000000..dc05048a1 --- /dev/null +++ b/examples/$/generated-clients/Pokemon/Select.ts @@ -0,0 +1,47 @@ +import type { ResultSet, SelectionSet } from '../../../../src/entrypoints/schema.js' +import type { Index } from './Index.js' + +// Runtime +// ------- + +import { createSelect } from '../../../../src/entrypoints/client.js' +export const Select = createSelect(`default`) + +// Buildtime +// --------- + +export namespace Select { + // Root Types + // ---------- + + export type Mutation<$SelectionSet extends SelectionSet.Root> = ResultSet.Root< + $SelectionSet, + Index, + 'Mutation' + > + + export type Query<$SelectionSet extends SelectionSet.Root> = ResultSet.Root< + $SelectionSet, + Index, + 'Query' + > + + // Object Types + // ------------ + + export type Pokemon<$SelectionSet extends SelectionSet.Object> = + ResultSet.Object$<$SelectionSet, Index['objects']['Pokemon'], Index> + + export type Trainer<$SelectionSet extends SelectionSet.Object> = + ResultSet.Object$<$SelectionSet, Index['objects']['Trainer'], Index> + + // Union Types + // ----------- + + // -- None -- + + // Interface Types + // --------------- + + // -- None -- +} diff --git a/examples/$generated-clients/SocialStudies/_.ts b/examples/$/generated-clients/Pokemon/_.ts similarity index 100% rename from examples/$generated-clients/SocialStudies/_.ts rename to examples/$/generated-clients/Pokemon/_.ts diff --git a/examples/$/generated-clients/Pokemon/__.ts b/examples/$/generated-clients/Pokemon/__.ts new file mode 100644 index 000000000..c27d0ff68 --- /dev/null +++ b/examples/$/generated-clients/Pokemon/__.ts @@ -0,0 +1 @@ +export * as Pokemon from './_.js' diff --git a/examples/$generated-clients/SocialStudies/Client.ts b/examples/$/generated-clients/SocialStudies/Client.ts similarity index 67% rename from examples/$generated-clients/SocialStudies/Client.ts rename to examples/$/generated-clients/SocialStudies/Client.ts index b016ac674..1c6395d03 100644 --- a/examples/$generated-clients/SocialStudies/Client.ts +++ b/examples/$/generated-clients/SocialStudies/Client.ts @@ -1,4 +1,4 @@ -import { createPrefilled } from '../../../src/entrypoints/client.js' +import { createPrefilled } from '../../../../src/entrypoints/client.js' import { $defaultSchemaUrl, $Index } from './SchemaRuntime.js' diff --git a/examples/$/generated-clients/SocialStudies/Error.ts b/examples/$/generated-clients/SocialStudies/Error.ts new file mode 100644 index 000000000..cd9eff6c7 --- /dev/null +++ b/examples/$/generated-clients/SocialStudies/Error.ts @@ -0,0 +1,14 @@ +type Include = Exclude> + +type ObjectWithTypeName = { __typename: string } + +const ErrorObjectsTypeNameSelectedEnum = {} as Record + +const ErrorObjectsTypeNameSelected = Object.values(ErrorObjectsTypeNameSelectedEnum) + +type ErrorObjectsTypeNameSelected = (typeof ErrorObjectsTypeNameSelected)[number] + +export const isError = <$Value>(value: $Value): value is Include<$Value, ErrorObjectsTypeNameSelected> => { + return typeof value === `object` && value !== null && `__typename` in value + && ErrorObjectsTypeNameSelected.some(_ => _.__typename === value.__typename) +} diff --git a/examples/$generated-clients/SocialStudies/Global.ts b/examples/$/generated-clients/SocialStudies/Global.ts similarity index 100% rename from examples/$generated-clients/SocialStudies/Global.ts rename to examples/$/generated-clients/SocialStudies/Global.ts diff --git a/examples/$generated-clients/SocialStudies/Index.ts b/examples/$/generated-clients/SocialStudies/Index.ts similarity index 100% rename from examples/$generated-clients/SocialStudies/Index.ts rename to examples/$/generated-clients/SocialStudies/Index.ts diff --git a/examples/$/generated-clients/SocialStudies/Scalar.ts b/examples/$/generated-clients/SocialStudies/Scalar.ts new file mode 100644 index 000000000..f7aca14c1 --- /dev/null +++ b/examples/$/generated-clients/SocialStudies/Scalar.ts @@ -0,0 +1 @@ +export * from '../../../../src/entrypoints/scalars.js' diff --git a/examples/$generated-clients/SocialStudies/SchemaBuildtime.ts b/examples/$/generated-clients/SocialStudies/SchemaBuildtime.ts similarity index 98% rename from examples/$generated-clients/SocialStudies/SchemaBuildtime.ts rename to examples/$/generated-clients/SocialStudies/SchemaBuildtime.ts index f0f40a5ff..15ac20900 100644 --- a/examples/$generated-clients/SocialStudies/SchemaBuildtime.ts +++ b/examples/$/generated-clients/SocialStudies/SchemaBuildtime.ts @@ -1,4 +1,4 @@ -import type * as $ from '../../../src/entrypoints/schema.js' +import type * as $ from '../../../../src/entrypoints/schema.js' import type * as $Scalar from './Scalar.ts' // ------------------------------------------------------------ // diff --git a/examples/$generated-clients/SocialStudies/SchemaRuntime.ts b/examples/$/generated-clients/SocialStudies/SchemaRuntime.ts similarity index 99% rename from examples/$generated-clients/SocialStudies/SchemaRuntime.ts rename to examples/$/generated-clients/SocialStudies/SchemaRuntime.ts index 142e09887..9a751799c 100644 --- a/examples/$generated-clients/SocialStudies/SchemaRuntime.ts +++ b/examples/$/generated-clients/SocialStudies/SchemaRuntime.ts @@ -1,6 +1,6 @@ /* eslint-disable */ -import * as $ from '../../../src/entrypoints/schema.js' +import * as $ from '../../../../src/entrypoints/schema.js' import * as $Scalar from './Scalar.js' export const $defaultSchemaUrl = new URL('https://countries.trevorblades.com/graphql') diff --git a/examples/$generated-clients/SocialStudies/Select.ts b/examples/$/generated-clients/SocialStudies/Select.ts similarity index 90% rename from examples/$generated-clients/SocialStudies/Select.ts rename to examples/$/generated-clients/SocialStudies/Select.ts index 4ed5c32bd..dfac8b569 100644 --- a/examples/$generated-clients/SocialStudies/Select.ts +++ b/examples/$/generated-clients/SocialStudies/Select.ts @@ -1,10 +1,10 @@ -import type { ResultSet, SelectionSet } from '../../../src/entrypoints/schema.js' +import type { ResultSet, SelectionSet } from '../../../../src/entrypoints/schema.js' import type { Index } from './Index.js' // Runtime // ------- -import { createSelect } from '../../../src/entrypoints/client.js' +import { createSelect } from '../../../../src/entrypoints/client.js' export const Select = createSelect(`default`) // Buildtime diff --git a/examples/$/generated-clients/SocialStudies/_.ts b/examples/$/generated-clients/SocialStudies/_.ts new file mode 100644 index 000000000..5e2498a39 --- /dev/null +++ b/examples/$/generated-clients/SocialStudies/_.ts @@ -0,0 +1,3 @@ +export { create } from './Client.js' +export { isError } from './Error.js' +export { Select } from './Select.js' diff --git a/examples/$generated-clients/SocialStudies/__.ts b/examples/$/generated-clients/SocialStudies/__.ts similarity index 100% rename from examples/$generated-clients/SocialStudies/__.ts rename to examples/$/generated-clients/SocialStudies/__.ts diff --git a/examples/$/helpers.ts b/examples/$/helpers.ts new file mode 100644 index 000000000..28073bb49 --- /dev/null +++ b/examples/$/helpers.ts @@ -0,0 +1,53 @@ +import getPort from 'get-port' +import type { GraphQLSchema } from 'graphql' +import { createYoga } from 'graphql-yoga' +import { createServer } from 'node:http' +import { inspect } from 'node:util' + +export const publicGraphQLSchemaEndpoints = { + SocialStudies: `https://countries.trevorblades.com/graphql`, +} + +export const showPartition = `---------------------------------------- SHOW ----------------------------------------` + +export const show = (value: unknown) => { + console.log(showPartition) + console.log(inspect(value, { depth: null, colors: true })) +} + +export const showJson = (value: unknown) => { + console.log(showPartition) + console.log(JSON.stringify(value, null, 2)) +} + +export const serveSchema = async (input: { schema: GraphQLSchema }) => { + const { schema } = input + const yoga = createYoga({ schema }) + const server = createServer(yoga) // eslint-disable-line + const port = await getPort({ port: [3000, 3001, 3002, 3003, 3004] }) + const url = new URL(`http://localhost:${String(port)}/graphql`) + server.listen(port) + await new Promise((resolve) => + server.once(`listening`, () => { + resolve(undefined) + }) + ) + const stop = async () => { + await new Promise((resolve) => { + server.close(resolve) + setImmediate(() => { + server.emit(`close`) + }) + }) + } + + return { + yoga, + server, + port, + url, + stop, + } +} + +export type SchemaServer = Awaited> diff --git a/examples/$/schemas/pokemon/generateSdl.ts b/examples/$/schemas/pokemon/generateSdl.ts new file mode 100644 index 000000000..157a0959e --- /dev/null +++ b/examples/$/schemas/pokemon/generateSdl.ts @@ -0,0 +1,14 @@ +import { printSchema } from 'graphql' +import { writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { schema } from './schema.js' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) + +const sdl = printSchema(schema) +const path = join(__dirname, `./schema.graphql`) +writeFileSync(path, sdl) + +console.log(`GraphQL SDL has been written to ${path}`) diff --git a/examples/$/schemas/pokemon/schema.graphql b/examples/$/schemas/pokemon/schema.graphql new file mode 100644 index 000000000..94b753c27 --- /dev/null +++ b/examples/$/schemas/pokemon/schema.graphql @@ -0,0 +1,25 @@ +type Mutation { + addPokemon(attack: Int!, defense: Int!, hp: Int!, name: String!): Pokemon +} + +type Pokemon { + attack: Int + defense: Int + hp: Int + id: Int + name: String + trainer: Trainer +} + +type Query { + pokemon: [Pokemon!] + pokemonByName(name: String!): [Pokemon!] + trainerByName(name: String!): Trainer + trainers: [Trainer!] +} + +type Trainer { + id: Int + name: String + pokemon: [Pokemon!] +} \ No newline at end of file diff --git a/examples/$/schemas/pokemon/schema.ts b/examples/$/schemas/pokemon/schema.ts new file mode 100644 index 000000000..bbe173208 --- /dev/null +++ b/examples/$/schemas/pokemon/schema.ts @@ -0,0 +1,142 @@ +import SchemaBuilder from '@pothos/core' +import SimpleObjectsPlugin from '@pothos/plugin-simple-objects' + +type Trainer = { + id: number + name: string +} + +type Pokemon = { + id: number + name: string + hp: number + attack: number + defense: number + trainerId: number | null // Nullable, as a Pokémon may not be captured by a trainer +} + +const builder = new SchemaBuilder<{ + Scalars: { + Date: { + Input: Date + Output: Date + } + } +}>({ + plugins: [SimpleObjectsPlugin], +}) + +type Database = { + trainers: Trainer[] + pokemon: Pokemon[] +} + +const Trainer = builder.objectRef(`Trainer`).implement({ + fields: (t) => ({ + id: t.int({ resolve: (trainer) => trainer.id }), + name: t.string({ resolve: (trainer) => trainer.name }), + }), +}) + +const Pokemon = builder.objectRef(`Pokemon`).implement({ + fields: (t) => ({ + id: t.int({ resolve: (pokemon) => pokemon.id }), + name: t.string({ resolve: (pokemon) => pokemon.name }), + hp: t.int({ resolve: (pokemon) => pokemon.hp }), + attack: t.int({ resolve: (pokemon) => pokemon.attack }), + defense: t.int({ resolve: (pokemon) => pokemon.defense }), + trainer: t.field({ + type: Trainer, + nullable: true, + resolve: (pokemon) => database.trainers.find((t) => t.id === pokemon.trainerId) || null, + }), + }), +}) + +builder.objectFields(Trainer, t => ({ + pokemon: t.field({ + type: t.listRef(Pokemon), + resolve: (trainer) => database.pokemon.filter((p) => p.trainerId === trainer.id), + }), +})) + +builder.queryType() +builder.mutationType() + +builder.queryField(`pokemon`, (t) => + t.field({ + type: [Pokemon], + resolve: () => database.pokemon, + })) + +builder.queryField(`pokemonByName`, (t) => + t.field({ + type: [Pokemon], + args: { + name: t.arg.string({ required: true }), + }, + resolve: (_, { name }) => database.pokemon.filter((p) => p.name.includes(name)), + })) + +builder.queryField(`trainers`, (t) => + t.field({ + type: [Trainer], + resolve: () => database.trainers, + })) + +builder.queryField(`trainerByName`, (t) => + t.field({ + type: Trainer, + args: { + name: t.arg.string({ required: true }), + }, + resolve: (_, { name }) => database.trainers.find((t) => t.name.includes(name)) || null, + })) + +builder.mutationField(`addPokemon`, (t) => + t.field({ + type: Pokemon, + args: { + name: t.arg.string({ required: true }), + hp: t.arg.int({ required: true }), + attack: t.arg.int({ required: true }), + defense: t.arg.int({ required: true }), + }, + resolve: (_, { name, hp, attack, defense }) => { + const newPokemon = { + id: database.pokemon.length + 1, + name, + hp, + attack, + defense, + trainerId: null, + } + database.pokemon.push(newPokemon) + return newPokemon + }, + })) + +const schema = builder.toSchema() + +const databaseSeedData = (database: Database) => { + const ash = { id: 1, name: `Ash` } + const misty = { id: 2, name: `Misty` } + + database.trainers.push(ash, misty) + + database.pokemon.push( + { id: 1, name: `Pikachu`, hp: 35, attack: 55, defense: 40, trainerId: 1 }, + { id: 2, name: `Charizard`, hp: 78, attack: 84, defense: 78, trainerId: 1 }, + { id: 3, name: `Squirtle`, hp: 44, attack: 48, defense: 65, trainerId: 2 }, + { id: 4, name: `Bulbasaur`, hp: 45, attack: 49, defense: 49, trainerId: null }, + ) +} + +const database: Database = { + trainers: [], + pokemon: [], +} + +databaseSeedData(database) + +export { database, schema } diff --git a/examples/$generated-clients/SocialStudies/Scalar.ts b/examples/$generated-clients/SocialStudies/Scalar.ts deleted file mode 100644 index 024c3c9ab..000000000 --- a/examples/$generated-clients/SocialStudies/Scalar.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '../../../src/entrypoints/scalars.js' diff --git a/examples/$helpers.ts b/examples/$helpers.ts deleted file mode 100644 index a0ee91412..000000000 --- a/examples/$helpers.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { inspect } from 'node:util' - -export const publicGraphQLSchemaEndpoints = { - SocialStudies: `https://countries.trevorblades.com/graphql`, -} - -export const show = (value: unknown) => { - console.log(inspect(value, { depth: null, colors: true })) -} - -export const showJson = (value: unknown) => { - console.log(JSON.stringify(value, null, 2)) -} diff --git a/examples/generated|generated_arguments__arguments.output.txt b/examples/generated|generated_arguments__arguments.output.txt index 5e563fc49..fda26a6ea 100644 --- a/examples/generated|generated_arguments__arguments.output.txt +++ b/examples/generated|generated_arguments__arguments.output.txt @@ -1,3 +1,4 @@ +---------------------------------------- SHOW ---------------------------------------- [ { "name": "Canada", diff --git a/examples/generated|generated_arguments__arguments.ts b/examples/generated|generated_arguments__arguments.ts index a0baab4c0..8fb770c49 100644 --- a/examples/generated|generated_arguments__arguments.ts +++ b/examples/generated|generated_arguments__arguments.ts @@ -1,5 +1,5 @@ -import { SocialStudies } from './$generated-clients/SocialStudies/__.js' -import { showJson } from './$helpers.js' +import { SocialStudies } from './$/generated-clients/SocialStudies/__.js' +import { showJson } from './$/helpers.js' const socialStudies = SocialStudies.create() diff --git a/examples/raw.output.txt b/examples/raw.output.txt index e59517e21..4e8a2edf4 100644 --- a/examples/raw.output.txt +++ b/examples/raw.output.txt @@ -1,3 +1,4 @@ +---------------------------------------- SHOW ---------------------------------------- { countries: [ { name: 'Canada', continent: { name: 'North America' } }, diff --git a/examples/raw.ts b/examples/raw.ts index a3f589a30..1596bd995 100644 --- a/examples/raw.ts +++ b/examples/raw.ts @@ -1,5 +1,5 @@ import { gql, Graffle } from '../src/entrypoints/main.js' -import { publicGraphQLSchemaEndpoints, show } from './$helpers.js' +import { publicGraphQLSchemaEndpoints, show } from './$/helpers.js' const graffle = Graffle.create({ schema: publicGraphQLSchemaEndpoints.SocialStudies, diff --git a/examples/raw_rawString__rawString.output.txt b/examples/raw_rawString__rawString.output.txt index fa3db0acb..a7bab2dd3 100644 --- a/examples/raw_rawString__rawString.output.txt +++ b/examples/raw_rawString__rawString.output.txt @@ -1,3 +1,4 @@ +---------------------------------------- SHOW ---------------------------------------- { countries: [ { name: 'Andorra' }, diff --git a/examples/raw_rawString__rawString.ts b/examples/raw_rawString__rawString.ts index fb7ec3b77..d024c8fcf 100644 --- a/examples/raw_rawString__rawString.ts +++ b/examples/raw_rawString__rawString.ts @@ -1,5 +1,5 @@ import { Graffle } from '../src/entrypoints/main.js' -import { publicGraphQLSchemaEndpoints, show } from './$helpers.js' +import { publicGraphQLSchemaEndpoints, show } from './$/helpers.js' const graffle = Graffle.create({ schema: publicGraphQLSchemaEndpoints.SocialStudies, diff --git a/examples/raw_rawString_rawTyped__rawString-typed.output.txt b/examples/raw_rawString_rawTyped__rawString-typed.output.txt index 33a0c2b0f..88e25626f 100644 --- a/examples/raw_rawString_rawTyped__rawString-typed.output.txt +++ b/examples/raw_rawString_rawTyped__rawString-typed.output.txt @@ -1,3 +1,4 @@ +---------------------------------------- SHOW ---------------------------------------- [ { name: 'Canada', continent: { name: 'North America' } }, { name: 'Germany', continent: { name: 'Europe' } }, diff --git a/examples/raw_rawString_rawTyped__rawString-typed.ts b/examples/raw_rawString_rawTyped__rawString-typed.ts index 799164df8..c00f78a7d 100644 --- a/examples/raw_rawString_rawTyped__rawString-typed.ts +++ b/examples/raw_rawString_rawTyped__rawString-typed.ts @@ -1,7 +1,7 @@ import { Graffle } from '../src/entrypoints/main.js' // todo from '../src/entrypoints/utils.js' import type { TypedDocumentString } from '../src/layers/0_functions/types.js' -import { publicGraphQLSchemaEndpoints, show } from './$helpers.js' +import { publicGraphQLSchemaEndpoints, show } from './$/helpers.js' const graffle = Graffle.create({ schema: publicGraphQLSchemaEndpoints.SocialStudies, diff --git a/examples/raw_rawTyped__raw-typed.output.txt b/examples/raw_rawTyped__raw-typed.output.txt index 3907b2161..4ff52a0cb 100644 --- a/examples/raw_rawTyped__raw-typed.output.txt +++ b/examples/raw_rawTyped__raw-typed.output.txt @@ -1,8 +1,10 @@ +---------------------------------------- SHOW ---------------------------------------- [ { name: 'Canada', continent: { name: 'North America' } }, { name: 'Germany', continent: { name: 'Europe' } }, { name: 'Japan', continent: { name: 'Asia' } } ] +---------------------------------------- SHOW ---------------------------------------- [ { name: 'Canada', continent: { name: 'North America' } }, { name: 'Germany', continent: { name: 'Europe' } }, diff --git a/examples/raw_rawTyped__raw-typed.ts b/examples/raw_rawTyped__raw-typed.ts index 743c93299..c0812ed69 100644 --- a/examples/raw_rawTyped__raw-typed.ts +++ b/examples/raw_rawTyped__raw-typed.ts @@ -1,6 +1,6 @@ import type { TypedQueryDocumentNode } from 'graphql' import { gql, Graffle } from '../src/entrypoints/main.js' -import { publicGraphQLSchemaEndpoints, show } from './$helpers.js' +import { publicGraphQLSchemaEndpoints, show } from './$/helpers.js' const graffle = Graffle.create({ schema: publicGraphQLSchemaEndpoints.SocialStudies, diff --git a/examples/transport-http_abort.output.txt b/examples/transport-http_abort.output.txt deleted file mode 100644 index b81379402..000000000 --- a/examples/transport-http_abort.output.txt +++ /dev/null @@ -1 +0,0 @@ -'This operation was aborted' \ No newline at end of file diff --git a/examples/transport-http_fetch.output.txt b/examples/transport-http_fetch.output.txt deleted file mode 100644 index 4158526ad..000000000 --- a/examples/transport-http_fetch.output.txt +++ /dev/null @@ -1,7 +0,0 @@ -{ - "countries": [ - { - "name": "Canada Mocked!" - } - ] -} \ No newline at end of file diff --git a/examples/transport-http_headers__dynamicHeaders.output-encoder.output.txt b/examples/transport-http_headers__dynamicHeaders.output-encoder.output.txt deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/transport-http_RequestInput.output.txt b/examples/transport-http|transport-http_RequestInput.output.test.txt similarity index 55% rename from examples/transport-http_RequestInput.output.txt rename to examples/transport-http|transport-http_RequestInput.output.test.txt index fd2d8bf2c..3cae29890 100644 --- a/examples/transport-http_RequestInput.output.txt +++ b/examples/transport-http|transport-http_RequestInput.output.test.txt @@ -1,11 +1,14 @@ +---------------------------------------- SHOW ---------------------------------------- { - url: 'https://countries.trevorblades.com/graphql', - body: '{"query":"{ languages { code } }"}', - method: 'POST', + methodMode: 'post', headers: Headers { accept: 'application/graphql-response+json; charset=utf-8, application/json; charset=utf-8', 'content-type': 'application/json', authorization: 'Bearer MY_TOKEN' }, - mode: 'cors' + signal: undefined, + mode: 'cors', + method: 'post', + url: 'https://countries.trevorblades.com/graphql', + body: '{"query":"{ languages { code } }"}' } \ No newline at end of file diff --git a/examples/transport-http|transport-http_RequestInput.output.txt b/examples/transport-http|transport-http_RequestInput.output.txt new file mode 100644 index 000000000..3cae29890 --- /dev/null +++ b/examples/transport-http|transport-http_RequestInput.output.txt @@ -0,0 +1,14 @@ +---------------------------------------- SHOW ---------------------------------------- +{ + methodMode: 'post', + headers: Headers { + accept: 'application/graphql-response+json; charset=utf-8, application/json; charset=utf-8', + 'content-type': 'application/json', + authorization: 'Bearer MY_TOKEN' + }, + signal: undefined, + mode: 'cors', + method: 'post', + url: 'https://countries.trevorblades.com/graphql', + body: '{"query":"{ languages { code } }"}' +} \ No newline at end of file diff --git a/examples/transport-http_RequestInput.ts b/examples/transport-http|transport-http_RequestInput.ts similarity index 70% rename from examples/transport-http_RequestInput.ts rename to examples/transport-http|transport-http_RequestInput.ts index 4c1da7066..4b1f74b39 100644 --- a/examples/transport-http_RequestInput.ts +++ b/examples/transport-http|transport-http_RequestInput.ts @@ -1,15 +1,17 @@ import { Graffle } from '../src/entrypoints/main.js' -import { show } from './$helpers.js' -import { publicGraphQLSchemaEndpoints } from './$helpers.js' +import { show } from './$/helpers.js' +import { publicGraphQLSchemaEndpoints } from './$/helpers.js' const graffle = Graffle .create({ schema: publicGraphQLSchemaEndpoints.SocialStudies, - request: { + transport: { headers: { authorization: `Bearer MY_TOKEN`, }, - mode: `cors`, + raw: { + mode: `cors`, + }, }, }) .use(async ({ exchange }) => { diff --git a/examples/transport-http|transport-http_abort.output.test.txt b/examples/transport-http|transport-http_abort.output.test.txt new file mode 100644 index 000000000..96e119c62 --- /dev/null +++ b/examples/transport-http|transport-http_abort.output.test.txt @@ -0,0 +1,2 @@ +---------------------------------------- SHOW ---------------------------------------- +'This operation was aborted' \ No newline at end of file diff --git a/examples/transport-http|transport-http_abort.output.txt b/examples/transport-http|transport-http_abort.output.txt new file mode 100644 index 000000000..96e119c62 --- /dev/null +++ b/examples/transport-http|transport-http_abort.output.txt @@ -0,0 +1,2 @@ +---------------------------------------- SHOW ---------------------------------------- +'This operation was aborted' \ No newline at end of file diff --git a/examples/transport-http_abort.ts b/examples/transport-http|transport-http_abort.ts similarity index 69% rename from examples/transport-http_abort.ts rename to examples/transport-http|transport-http_abort.ts index b1346820c..96b6eb9ad 100644 --- a/examples/transport-http_abort.ts +++ b/examples/transport-http|transport-http_abort.ts @@ -2,8 +2,8 @@ * It is possible to cancel a request using an `AbortController` signal. */ -import { gql, Graffle } from '../src/entrypoints/main.js' -import { publicGraphQLSchemaEndpoints, show } from './$helpers.js' +import { Graffle } from '../src/entrypoints/main.js' +import { publicGraphQLSchemaEndpoints, show } from './$/helpers.js' const abortController = new AbortController() @@ -12,9 +12,9 @@ const graffle = Graffle.create({ }) const resultPromise = graffle - .with({ request: { signal: abortController.signal } }) - .raw({ - document: gql` + .with({ transport: { signal: abortController.signal } }) + .rawString({ + document: ` { countries { name diff --git a/examples/transport-http|transport-http_fetch.output.test.txt b/examples/transport-http|transport-http_fetch.output.test.txt new file mode 100644 index 000000000..eb2b08f40 --- /dev/null +++ b/examples/transport-http|transport-http_fetch.output.test.txt @@ -0,0 +1,8 @@ +---------------------------------------- SHOW ---------------------------------------- +{ + "countries": [ + { + "name": "Canada Mocked!" + } + ] +} \ No newline at end of file diff --git a/examples/transport-http|transport-http_fetch.output.txt b/examples/transport-http|transport-http_fetch.output.txt new file mode 100644 index 000000000..eb2b08f40 --- /dev/null +++ b/examples/transport-http|transport-http_fetch.output.txt @@ -0,0 +1,8 @@ +---------------------------------------- SHOW ---------------------------------------- +{ + "countries": [ + { + "name": "Canada Mocked!" + } + ] +} \ No newline at end of file diff --git a/examples/transport-http_fetch.ts b/examples/transport-http|transport-http_fetch.ts similarity index 84% rename from examples/transport-http_fetch.ts rename to examples/transport-http|transport-http_fetch.ts index f51812a1c..7d58247b3 100644 --- a/examples/transport-http_fetch.ts +++ b/examples/transport-http|transport-http_fetch.ts @@ -1,7 +1,7 @@ /* eslint-disable */ import { Graffle } from '../src/entrypoints/main.js' -import { showJson } from './$helpers.js' -import { publicGraphQLSchemaEndpoints } from './$helpers.js' +import { showJson } from './$/helpers.js' +import { publicGraphQLSchemaEndpoints } from './$/helpers.js' const graffle = Graffle .create({ schema: publicGraphQLSchemaEndpoints.SocialStudies }) diff --git a/examples/transport-http_headers__dynamicHeaders.output.txt b/examples/transport-http|transport-http_headers__dynamicHeaders.output.test.txt similarity index 56% rename from examples/transport-http_headers__dynamicHeaders.output.txt rename to examples/transport-http|transport-http_headers__dynamicHeaders.output.test.txt index 3414a8878..a6153f31a 100644 --- a/examples/transport-http_headers__dynamicHeaders.output.txt +++ b/examples/transport-http|transport-http_headers__dynamicHeaders.output.test.txt @@ -1,10 +1,13 @@ +---------------------------------------- SHOW ---------------------------------------- { - url: 'https://countries.trevorblades.com/graphql', - body: '{"query":"{ languages { code } }"}', - method: 'POST', + methodMode: 'post', headers: Headers { accept: 'application/graphql-response+json; charset=utf-8, application/json; charset=utf-8', 'content-type': 'application/json', 'x-sent-at-time': 'DYNAMIC_VALUE' - } + }, + signal: undefined, + method: 'post', + url: 'https://countries.trevorblades.com/graphql', + body: '{"query":"{ languages { code } }"}' } \ No newline at end of file diff --git a/examples/transport-http|transport-http_headers__dynamicHeaders.output.txt b/examples/transport-http|transport-http_headers__dynamicHeaders.output.txt new file mode 100644 index 000000000..ff42ef909 --- /dev/null +++ b/examples/transport-http|transport-http_headers__dynamicHeaders.output.txt @@ -0,0 +1,13 @@ +---------------------------------------- SHOW ---------------------------------------- +{ + methodMode: 'post', + headers: Headers { + accept: 'application/graphql-response+json; charset=utf-8, application/json; charset=utf-8', + 'content-type': 'application/json', + 'x-sent-at-time': '1725648269194' + }, + signal: undefined, + method: 'post', + url: 'https://countries.trevorblades.com/graphql', + body: '{"query":"{ languages { code } }"}' +} \ No newline at end of file diff --git a/examples/transport-http_headers__dynamicHeaders.ts b/examples/transport-http|transport-http_headers__dynamicHeaders.ts similarity index 82% rename from examples/transport-http_headers__dynamicHeaders.ts rename to examples/transport-http|transport-http_headers__dynamicHeaders.ts index 74e90347c..91df68286 100644 --- a/examples/transport-http_headers__dynamicHeaders.ts +++ b/examples/transport-http|transport-http_headers__dynamicHeaders.ts @@ -1,5 +1,5 @@ import { Graffle } from '../src/entrypoints/main.js' -import { publicGraphQLSchemaEndpoints, show } from './$helpers.js' +import { publicGraphQLSchemaEndpoints, show } from './$/helpers.js' const graffle = Graffle .create({ @@ -16,6 +16,7 @@ const graffle = Graffle }) }) .use(async ({ exchange }) => { + // todo wrong type / runtime value show(exchange.input.request) return exchange() }) diff --git a/examples/transport-http|transport-http_method-get.output.test.txt b/examples/transport-http|transport-http_method-get.output.test.txt new file mode 100644 index 000000000..8047d5a65 --- /dev/null +++ b/examples/transport-http|transport-http_method-get.output.test.txt @@ -0,0 +1,48 @@ +---------------------------------------- SHOW ---------------------------------------- +{ + methodMode: 'getReads', + headers: Headers { + accept: 'application/graphql-response+json; charset=utf-8, application/json; charset=utf-8', + 'content-type': 'application/json' + }, + signal: undefined, + method: 'post', + url: URL { + href: 'http://localhost:3000/graphql', + origin: 'http://localhost:3000', + protocol: 'http:', + username: '', + password: '', + host: 'localhost:3000', + hostname: 'localhost', + port: '3000', + pathname: '/graphql', + search: '', + searchParams: URLSearchParams {}, + hash: '' + }, + body: '{"query":"mutation addPokemon(attack:0, defense:0, hp:1, name:\\"Nano\\") { name }"}' +} +---------------------------------------- SHOW ---------------------------------------- +{ + methodMode: 'getReads', + headers: Headers { + accept: 'application/graphql-response+json; charset=utf-8, application/json; charset=utf-8' + }, + signal: undefined, + method: 'get', + url: URL { + href: 'http://localhost:3000/graphql?query=query+%7B+pokemonByName%28name%3A%22Nano%22%29+%7B+hp+%7D+%7D', + origin: 'http://localhost:3000', + protocol: 'http:', + username: '', + password: '', + host: 'localhost:3000', + hostname: 'localhost', + port: '3000', + pathname: '/graphql', + search: '?query=query+%7B+pokemonByName%28name%3A%22Nano%22%29+%7B+hp+%7D+%7D', + searchParams: URLSearchParams { 'query' => 'query { pokemonByName(name:"Nano") { hp } }' }, + hash: '' + } +} \ No newline at end of file diff --git a/examples/transport-http|transport-http_method-get.output.txt b/examples/transport-http|transport-http_method-get.output.txt new file mode 100644 index 000000000..8047d5a65 --- /dev/null +++ b/examples/transport-http|transport-http_method-get.output.txt @@ -0,0 +1,48 @@ +---------------------------------------- SHOW ---------------------------------------- +{ + methodMode: 'getReads', + headers: Headers { + accept: 'application/graphql-response+json; charset=utf-8, application/json; charset=utf-8', + 'content-type': 'application/json' + }, + signal: undefined, + method: 'post', + url: URL { + href: 'http://localhost:3000/graphql', + origin: 'http://localhost:3000', + protocol: 'http:', + username: '', + password: '', + host: 'localhost:3000', + hostname: 'localhost', + port: '3000', + pathname: '/graphql', + search: '', + searchParams: URLSearchParams {}, + hash: '' + }, + body: '{"query":"mutation addPokemon(attack:0, defense:0, hp:1, name:\\"Nano\\") { name }"}' +} +---------------------------------------- SHOW ---------------------------------------- +{ + methodMode: 'getReads', + headers: Headers { + accept: 'application/graphql-response+json; charset=utf-8, application/json; charset=utf-8' + }, + signal: undefined, + method: 'get', + url: URL { + href: 'http://localhost:3000/graphql?query=query+%7B+pokemonByName%28name%3A%22Nano%22%29+%7B+hp+%7D+%7D', + origin: 'http://localhost:3000', + protocol: 'http:', + username: '', + password: '', + host: 'localhost:3000', + hostname: 'localhost', + port: '3000', + pathname: '/graphql', + search: '?query=query+%7B+pokemonByName%28name%3A%22Nano%22%29+%7B+hp+%7D+%7D', + searchParams: URLSearchParams { 'query' => 'query { pokemonByName(name:"Nano") { hp } }' }, + hash: '' + } +} \ No newline at end of file diff --git a/examples/transport-http|transport-http_method-get.ts b/examples/transport-http|transport-http_method-get.ts new file mode 100644 index 000000000..fe94b24d1 --- /dev/null +++ b/examples/transport-http|transport-http_method-get.ts @@ -0,0 +1,30 @@ +/** + * This example shows usage of the `getReads` method mode for the HTTP transport. This mode causes read-kind operations (query, subscription) + * to be sent over HTTP GET method. Note write-kind operations (mutation) are still sent over HTTP POST method. + */ + +import { Pokemon } from './$/generated-clients/Pokemon/__.js' +import { serveSchema, show } from './$/helpers.js' +import { schema } from './$/schemas/pokemon/schema.js' + +const server = await serveSchema({ schema }) + +const graffle = Pokemon + .create({ + schema: server.url, + transport: { methodMode: `getReads` }, // [!code highlight] + }) + .use(async ({ exchange }) => { + show(exchange.input.request) + return exchange() + }) + +// The following request will use an HTTP POST method because it is +// using a "mutation" type of operation. +await graffle.rawString({ document: `mutation addPokemon(attack:0, defense:0, hp:1, name:"Nano") { name }` }) + +// The following request will use an HTTP GET method because it +// is using a "query" type of operation. +await graffle.rawString({ document: `query { pokemonByName(name:"Nano") { hp } }` }) + +await server.stop() diff --git a/examples/transport-memory.output.txt b/examples/transport-memory.output.txt index 6f1de879d..673c0f2e2 100644 --- a/examples/transport-memory.output.txt +++ b/examples/transport-memory.output.txt @@ -1,3 +1,4 @@ +---------------------------------------- SHOW ---------------------------------------- { "data": { "foo": "bar" diff --git a/examples/transport-memory.ts b/examples/transport-memory.ts index 9b60f04e5..2a1618136 100644 --- a/examples/transport-memory.ts +++ b/examples/transport-memory.ts @@ -1,6 +1,6 @@ import { GraphQLObjectType, GraphQLSchema, GraphQLString } from 'graphql' import { Graffle } from '../src/entrypoints/main.js' -import { showJson } from './$helpers.js' +import { showJson } from './$/helpers.js' const schema = new GraphQLSchema({ query: new GraphQLObjectType({ diff --git a/package.json b/package.json index c880c1f6a..baec1f4e4 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "graffle": "tsx ./src/cli/generate.ts", "gen:test:schema": "tsx tests/_/schemaGenerate.ts", "gen:examples": "tsx scripts/generate-examples-derivatives/generate.ts && pnpm format", - "gen:docs:examples:clients:SocialStudies": "pnpm graffle --name SocialStudies --schema https://countries.trevorblades.com/graphql --output ./examples/generated-clients/SocialStudies --libraryPathClient ../../../src/entrypoints/client.js --libraryPathSchema ../../../src/entrypoints/schema.js --libraryPathScalars ../../../src/entrypoints/scalars.js", + "gen:docs:examples:clients": "tsx './examples/$/schemas/pokemon/generateSdl.ts' && pnpm graffle --name Pokemon --schema './examples/$/schemas/pokemon/schema.graphql' --output './examples/$/generated-clients/Pokemon' --libraryPathClient ../../../../src/entrypoints/client.js --libraryPathSchema ../../../../src/entrypoints/schema.js --libraryPathScalars ../../../../src/entrypoints/scalars.js && pnpm graffle --name SocialStudies --schema https://countries.trevorblades.com/graphql --output './examples/$/generated-clients/SocialStudies' --libraryPathClient ../../../../src/entrypoints/client.js --libraryPathSchema ../../../../src/entrypoints/schema.js --libraryPathScalars ../../../../src/entrypoints/scalars.js", "dev": "rm -rf dist && tsc --watch", "format": "pnpm build:docs && dprint fmt", "lint": "eslint . --fix", diff --git a/scripts/generate-examples-derivatives/generate-docs.ts b/scripts/generate-examples-derivatives/generate-docs.ts index a25370545..f1dc689d7 100644 --- a/scripts/generate-examples-derivatives/generate-docs.ts +++ b/scripts/generate-examples-derivatives/generate-docs.ts @@ -1,33 +1,12 @@ import { groupBy } from 'es-toolkit' import * as FS from 'node:fs/promises' import { type DefaultTheme } from 'vitepress' -import { publicGraphQLSchemaEndpoints } from '../../examples/$helpers.js' +import { publicGraphQLSchemaEndpoints } from '../../examples/$/helpers.js' import { deleteFiles } from '../lib/deleteFiles.js' -import type { File } from '../lib/readFiles.js' -import { readFiles } from '../lib/readFiles.js' +import { computeCombinations, type Example, readExamples, toTitle } from './helpers.js' export const generateDocs = async () => { - const exampleFiles = await readFiles({ - pattern: `./examples/*.ts`, - options: { ignore: [`./examples/$*`] }, - }) - - const outputFiles = await readFiles({ - pattern: `./examples/*.output.txt`, - }) - - const examples = exampleFiles.map(example => { - const output = outputFiles.find(file => file.name === `${example.name}.output.txt`) - if (!output) throw new Error(`Could not find output file for ${example.name}`) - - return { - file: example, - fileName: parseFileName(example.name), - output, - isUsingJsonOutput: example.content.includes(`showJson`), - tags: parseTags(example.name), - } - }) + const examples = await readExamples() const examplesTransformed = examples .map(transformOther) @@ -74,9 +53,9 @@ export const generateDocs = async () => { ).sort((a) => a.items ? 1 : -1) const code = ` - import { DefaultTheme } from 'vitepress' + import { DefaultTheme } from 'vitepress' - export const sidebarExamples:DefaultTheme.SidebarItem[] = ${JSON.stringify(sidebarExamples, null, 2)} + export const sidebarExamples:DefaultTheme.SidebarItem[] = ${JSON.stringify(sidebarExamples, null, 2)} ` await FS.writeFile(`./website/.vitepress/configExamples.ts`, code) @@ -120,67 +99,6 @@ export const generateDocs = async () => { console.log(`Generated a Vitepress Markdown partial for each example tags combination.`) } -const computeCombinations = (arr: string[]): string[][] => { - const result: string[][] = [] - - const generateCombinations = (currentCombination: string[], index: number) => { - if (index === arr.length) { - result.push([...currentCombination]) - return - } - - // Include the current element - generateCombinations([...currentCombination, arr[index]!], index + 1) - - // Exclude the current element - generateCombinations(currentCombination, index + 1) - } - - generateCombinations([], 0) - - return result -} - -const toTitle = (name: string) => name.split(`-`).map(titlizeWord).join(` `).split(`_`).map(titlizeWord).join(` `) - -const titlizeWord = (word: string) => word.charAt(0).toUpperCase() + word.slice(1) - -const parseFileName = (fileName: string): Example['fileName'] => { - const [group, fileNameWithoutGroup] = fileName.includes(`|`) ? fileName.split(`|`) : [null, fileName] - const [tagsExpression, titleExpression] = fileNameWithoutGroup.split(`__`) - const canonicalTitleExpression = titleExpression ?? tagsExpression ?? `impossible` - return { - canonical: (group ? `${group}-` : ``) + canonicalTitleExpression, - canonicalTitle: toTitle(canonicalTitleExpression), - tags: tagsExpression ?? `impossible`, - title: titleExpression ?? null, - group, - } -} - -type Tag = string - -const parseTags = (fileName: string) => { - const [tagsExpression] = fileName.split(`__`) - if (!tagsExpression) return [] - const tags = tagsExpression.split(`_`) - return tags -} - -interface Example { - file: File - fileName: { - canonical: string - canonicalTitle: string - tags: string - title: string | null - group: null | string - } - output: File - isUsingJsonOutput: boolean - tags: Tag[] -} - /** * Define Transformers * ------------------- @@ -227,7 +145,7 @@ import { Graffle as SocialStudies } from './graffle/__.js'`, const transformRewriteHelperImports = (example: Example) => { const consoleLog = `console.log` const newContent = example.file.content - .replaceAll(/^import.*\$helpers.*$\n/gm, ``) + .replaceAll(/^import.*\$\/helpers.*$\n/gm, ``) .replaceAll( `publicGraphQLSchemaEndpoints.SocialStudies`, `\`${publicGraphQLSchemaEndpoints.SocialStudies}\``, @@ -275,18 +193,27 @@ const transformMarkdown = (example: Example) => { aside: false --- -# ${example.fileName.canonicalTitle} +# ${example.fileName.canonicalTitle}${example.description ? `\n\n${example.description}\n` : ``} + \`\`\`ts twoslash ${example.file.content.trim()} \`\`\` + -#### Output +#### Outputs +${ + example.output.blocks.map(block => { + return ` + \`\`\`${example.isUsingJsonOutput ? `json` : `txt`} -${example.output.content.trim()} +${block} \`\`\` - + +`.trim() + }).join(`\n`) + } `.trim() return { diff --git a/scripts/generate-examples-derivatives/generate-outputs.ts b/scripts/generate-examples-derivatives/generate-outputs.ts index 579c3b251..fe11268e0 100644 --- a/scripts/generate-examples-derivatives/generate-outputs.ts +++ b/scripts/generate-examples-derivatives/generate-outputs.ts @@ -2,18 +2,15 @@ import { execaCommand } from 'execa' import * as FS from 'node:fs/promises' import stripAnsi from 'strip-ansi' import { deleteFiles } from '../lib/deleteFiles.js' -import { readFiles } from '../lib/readFiles.js' +import { readExampleFiles } from './helpers.js' export const generateOutputs = async () => { // Handle case of renaming or deleting examples. await deleteFiles({ pattern: `./examples/*.output.*` }) - const files = await readFiles({ - pattern: `./examples/*.ts`, - options: { ignore: [`./examples/$*`, `./examples/*.output.*`] }, - }) + const exampleFiles = await readExampleFiles() - await Promise.all(files.map(async (file) => { + await Promise.all(exampleFiles.map(async (file) => { const result = await execaCommand(`pnpm tsx ./examples/${file.name}.ts`) const exampleResult = stripAnsi(result.stdout) await FS.writeFile(`./examples/${file.name}.output.txt`, exampleResult) diff --git a/scripts/generate-examples-derivatives/generate-tests.ts b/scripts/generate-examples-derivatives/generate-tests.ts index 2326b3c66..699fdcce9 100644 --- a/scripts/generate-examples-derivatives/generate-tests.ts +++ b/scripts/generate-examples-derivatives/generate-tests.ts @@ -8,7 +8,7 @@ export const generateTests = async () => { // Handle case of renaming or deleting examples. await deleteFiles({ pattern: `./tests/examples/*.test.ts` }) - const files = await readFiles({ + const exampleFiles = await readFiles({ pattern: `./examples/*.ts`, options: { ignore: [`./examples/$*`, `./examples/*.output.*`, `./examples/*.output-encoder.*`] }, }) @@ -16,7 +16,7 @@ export const generateTests = async () => { const outputDir = Path.join(process.cwd(), `./tests/examples`) - await Promise.all(files.map(async (file) => { + await Promise.all(exampleFiles.map(async (file) => { const encoderFilePath = encoderFilePaths.find((encoderFilePath) => encoderFilePath.match(new RegExp(`${file.name}.output-encoder.ts`)) !== null ) @@ -41,7 +41,9 @@ test(\`${file.name}\`, async () => { const exampleResult = ${encoderFilePath ? `encode(stripAnsi(result.stdout))` : `stripAnsi(result.stdout)`} // If ever outputs vary by Node version, you can use this to snapshot by Node version. // const nodeMajor = process.version.match(/v(\\d+)/)?.[1] ?? \`unknown\` - await expect(exampleResult).toMatchFileSnapshot(\`../../${file.path.dir}/${file.name}.output.txt\`) + await expect(exampleResult).toMatchFileSnapshot(\`../../${file.path.dir}/${file.name}.output${ + encoderFilePath ? `.test` : `` + }.txt\`) }) ` diff --git a/scripts/generate-examples-derivatives/helpers.ts b/scripts/generate-examples-derivatives/helpers.ts new file mode 100644 index 000000000..9a2162639 --- /dev/null +++ b/scripts/generate-examples-derivatives/helpers.ts @@ -0,0 +1,130 @@ +import { capitalize, kebabCase } from 'es-toolkit' +import { showPartition } from '../../examples/$/helpers.js' +import { type File, readFiles } from '../lib/readFiles.js' + +export const examplesIgnorePatterns = [`./examples/$*`, `./examples/*.output.*`, `./examples/*.output-encoder.*`] + +export const readExampleFiles = () => + readFiles({ + pattern: `./examples/*.ts`, + options: { ignore: examplesIgnorePatterns }, + }) + +export const readExamples = async (): Promise => { + const exampleFiles = await readExampleFiles() + + const outputFiles = await readFiles({ + pattern: `./examples/*.output.txt`, + }) + + const examples = exampleFiles.map((example) => { + const outputFile = outputFiles.find(file => file.name === `${example.name}.output.txt`) + if (!outputFile) throw new Error(`Could not find output file for ${example.name}`) + + const { description, content } = extractDescription(example.content) + + return { + file: { + ...example, + content, + }, + fileName: parseFileName(example.name), + output: { + file: outputFile, + blocks: outputFile.content.split(showPartition + `\n`).map(block => block.trim()).filter(Boolean), + }, + isUsingJsonOutput: example.content.includes(`showJson`), + description, + tags: parseTags(example.name), + } + }) + + return examples +} + +const parseFileName = (fileName: string): Example['fileName'] => { + const [group, fileNameWithoutGroup] = fileName.includes(`|`) ? fileName.split(`|`) : [null, fileName] + const [tagsExpression, titleExpression] = fileNameWithoutGroup.split(`__`) + // If group name is duplicated by tags then omit that from the canonical title. + const tagsExpressionWithoutGroupName = tagsExpression + ? parseTags(tagsExpression).map(tag => tag === group ? `` : tag).filter(Boolean).join(` `) + : null + const canonicalTitleExpression = titleExpression ?? tagsExpressionWithoutGroupName ?? `impossible` + return { + canonical: (group ? `${group}-` : ``) + kebabCase(canonicalTitleExpression), + canonicalTitle: toTitle(canonicalTitleExpression), + tags: tagsExpression ?? `impossible`, + title: titleExpression ?? null, + group, + } +} + +const parseTags = (fileName: string) => { + const [tagsExpression] = fileName.replace(/^[^|]+\|/, ``).split(`__`) + console.log(tagsExpression) + if (!tagsExpression) return [] + const tags = tagsExpression.split(`_`) + return tags +} + +export interface Example { + description: string | null + file: File + fileName: { + canonical: string + canonicalTitle: string + tags: string + title: string | null + group: null | string + } + output: { + file: File + blocks: string[] + } + isUsingJsonOutput: boolean + tags: Tag[] +} + +type Tag = string + +const extractDescription = (fileContent: string) => { + const pattern = /^\/\*\*([\s\S]*?)\*\// + const jsdocMatch = fileContent.match(pattern) + + if (jsdocMatch) { + const description = jsdocMatch[1]!.trim().replaceAll(/^\s*\* /gm, ``) + console.log(description) + return { + description, + content: fileContent.replace(pattern, ``), + } + } + + return { + description: null, + content: fileContent, + } +} + +export const toTitle = (name: string) => kebabCase(name).split(`-`).map(capitalize).join(` `) + +export const computeCombinations = (arr: string[]): string[][] => { + const result: string[][] = [] + + const generateCombinations = (currentCombination: string[], index: number) => { + if (index === arr.length) { + result.push([...currentCombination]) + return + } + + // Include the current element + generateCombinations([...currentCombination, arr[index]!], index + 1) + + // Exclude the current element + generateCombinations(currentCombination, index + 1) + } + + generateCombinations([], 0) + + return result +} diff --git a/src/layers/3_SelectionSet/encode.test.ts b/src/layers/3_SelectionSet/encode.test.ts index 4ef89a0a0..9c83e7e1e 100644 --- a/src/layers/3_SelectionSet/encode.test.ts +++ b/src/layers/3_SelectionSet/encode.test.ts @@ -26,11 +26,10 @@ const testEachArgs = [ schemaIndex, config: { output: outputConfigDefault, - transport: `memory`, + transport: { type: `memory`, config: { methodMode: `post` } }, name: schemaIndex[`name`], // eslint-disable-next-line initialInput: {} as any, - requestInputOptions: {}, }, } const graphqlDocumentString = rootTypeSelectionSet(context, schemaIndex[`Root`][`Query`], ss as any) diff --git a/src/layers/5_core/core.ts b/src/layers/5_core/core.ts index dcec0eb95..8309d398e 100644 --- a/src/layers/5_core/core.ts +++ b/src/layers/5_core/core.ts @@ -1,9 +1,22 @@ import type { DocumentNode, ExecutionResult, GraphQLSchema } from 'graphql' import { print } from 'graphql' import { Anyware } from '../../lib/anyware/__.js' -import { type StandardScalarVariables } from '../../lib/graphql.js' -import { ACCEPT_REC, CONTENT_TYPE_REC, parseExecutionResult } from '../../lib/graphqlHTTP.js' -import { casesExhausted } from '../../lib/prelude.js' +import { + type GraphQLRequestEncoded, + type GraphQLRequestInput, + OperationTypeAccessTypeMap, + parseGraphQLOperationType, + type StandardScalarVariables, +} from '../../lib/graphql.js' +import { + getRequestEncodeSearchParameters, + getRequestHeadersRec, + parseExecutionResult, + postRequestEncodeBody, + postRequestHeadersRec, +} from '../../lib/graphqlHTTP.js' +import { mergeRequestInit, searchParamsAppendAll } from '../../lib/http.js' +import { casesExhausted, throwNull } from '../../lib/prelude.js' import { execute } from '../0_functions/execute.js' import type { Schema } from '../1_Schema/__.js' import { SelectionSet } from '../3_SelectionSet/__.js' @@ -11,7 +24,12 @@ import type { GraphQLObjectSelection } from '../3_SelectionSet/encode.js' import * as Result from '../4_ResultSet/customScalars.js' import type { GraffleExecutionResultVar } from '../6_client/client.js' import type { Config } from '../6_client/Settings/Config.js' -import { mergeRequestInputOptions, type RequestInput } from '../6_client/Settings/inputIncrementable/request.js' +import { + type CoreExchangeGetRequest, + type CoreExchangePostRequest, + MethodMode, + type MethodModeGetReads, +} from '../6_client/transportHttp/request.js' import type { ContextInterfaceRaw, ContextInterfaceTyped, @@ -45,7 +63,7 @@ type InterfaceInput = // eslint-disable-next-line type TransportInput<$Config extends Config, $HttpProperties = {}, $MemoryProperties = {}> = | ( - TransportHttp extends $Config['transport'] + TransportHttp extends $Config['transport']['type'] ? ({ transport: TransportHttp @@ -53,7 +71,7 @@ type TransportInput<$Config extends Config, $HttpProperties = {}, $MemoryPropert : never ) | ( - TransportMemory extends $Config['transport'] + TransportMemory extends $Config['transport']['type'] ? ({ transport: TransportMemory } & $MemoryProperties) @@ -66,30 +84,28 @@ export type HookSequence = typeof hookNamesOrderedBySequence export type HookDefEncode<$Config extends Config> = { input: - & InterfaceInput< - { selection: GraphQLObjectSelection }, - { document: string | DocumentNode; variables?: StandardScalarVariables; operationName?: string } - > + & InterfaceInput<{ selection: GraphQLObjectSelection }, GraphQLRequestInput> & TransportInput<$Config, { schema: string | URL }, { schema: GraphQLSchema }> - slots: { - /** - * Create the value that will be used as the HTTP body for the sent GraphQL request. - */ - body: ( - input: { query: string; variables?: StandardScalarVariables; operationName?: string }, - ) => BodyInit - } } export type HookDefPack<$Config extends Config> = { input: + & GraphQLRequestEncoded & InterfaceInput - & TransportInput<$Config, { url: string | URL; headers?: HeadersInit; body: BodyInit }, { + // todo why is headers here but not other http request properties? + & TransportInput<$Config, { url: string | URL; headers?: HeadersInit }, { schema: GraphQLSchema - query: string - variables?: StandardScalarVariables - operationName?: string }> + slots: { + /** + * When request will be sent using POST this slot is called to create the value that will be used for the HTTP body. + */ + searchParams: getRequestEncodeSearchParameters + /** + * When request will be sent using POST this slot is called to create the value that will be used for the HTTP body. + */ + body: postRequestEncodeBody + } } export type HookDefExchange<$Config extends Config> = { @@ -99,7 +115,7 @@ export type HookDefExchange<$Config extends Config> = { input: & InterfaceInput & TransportInput<$Config, { - request: RequestInput + request: CoreExchangePostRequest | CoreExchangeGetRequest }, { schema: GraphQLSchema query: string | DocumentNode @@ -134,121 +150,132 @@ export type HookMap<$Config extends Config = Config> = { export const anyware = Anyware.create({ hookNamesOrderedBySequence, hooks: { - encode: { - slots: { - body: (input) => { - return JSON.stringify({ - query: input.query, - variables: input.variables, - operationName: input.operationName, - }) - }, - }, - run: ({ input, slots }) => { - let document: string - let variables: StandardScalarVariables | undefined = undefined - let operationName: string | undefined = undefined + encode: ({ input }) => { + let document: string + let variables: StandardScalarVariables | undefined = undefined + let operationName: string | undefined = undefined - switch (input.interface) { - case `raw`: { - const documentPrinted = typeof input.document === `string` - ? input.document - : print(input.document) - document = documentPrinted - variables = input.variables - operationName = input.operationName - break - } - case `typed`: { - // todo turn inputs into variables - variables = undefined - document = SelectionSet.Print.rootTypeSelectionSet( - input.context, - getRootIndexOrThrow(input.context, input.rootTypeName), - input.selection, - ) - break - } - default: - throw casesExhausted(input) + switch (input.interface) { + case `raw`: { + const documentPrinted = typeof input.document === `string` + ? input.document + : print(input.document) + document = documentPrinted + variables = input.variables + operationName = input.operationName + break } + case `typed`: { + // todo turn inputs into variables + variables = undefined + document = SelectionSet.Print.rootTypeSelectionSet( + input.context, + getRootIndexOrThrow(input.context, input.rootTypeName), + input.selection, + ) + break + } + default: + throw casesExhausted(input) + } - switch (input.transport) { - case `http`: { - const body = slots.body({ - query: document, - variables, - operationName, - }) - - return { - ...input, - url: input.schema, - body, - } - } - case `memory`: { - return { - ...input, - schema: input.schema, - query: document, - variables, - operationName, - } + switch (input.transport) { + case `http`: { + return { + ...input, + url: input.schema, + query: document, + variables, + operationName, } } - }, - }, - pack: ({ input }) => { - switch (input.transport) { case `memory`: { - return input + return { + ...input, + schema: input.schema, + query: document, + variables, + operationName, + } } - case `http`: { - // todo support GET - // TODO thrown error here is swallowed in examples. - const request: RequestInput = { - url: input.url, - body: input.body, - // @see https://graphql.github.io/graphql-over-http/draft/#sec-POST - method: `POST`, - ...mergeRequestInputOptions( - mergeRequestInputOptions( - { - headers: { - accept: ACCEPT_REC, - 'content-type': CONTENT_TYPE_REC, + } + }, + pack: { + slots: { + searchParams: getRequestEncodeSearchParameters, + body: postRequestEncodeBody, + }, + run: ({ input, slots }) => { + // TODO thrown error here is swallowed in examples. + switch (input.transport) { + case `memory`: { + return input + } + case `http`: { + const methodMode = input.context.config.transport.config.methodMode + // todo parsing here can be optimized. + // 1. If using TS interface then work with initially submitted structured data to already know the operation type + // 2. Maybe: Memoize over request.{ operationName, query } + // 3. Maybe: Keep a cache of parsed request.{ query } + const operationType = throwNull(parseGraphQLOperationType(input)) // todo better feedback here than throwNull + const requestMethod = methodMode === MethodMode.post + ? `post` + : methodMode === MethodMode.getReads // eslint-disable-line + ? OperationTypeAccessTypeMap[operationType] === `read` ? `get` : `post` + : casesExhausted(methodMode) + + const baseProperties = mergeRequestInit( + mergeRequestInit( + mergeRequestInit( + { + headers: requestMethod === `get` ? getRequestHeadersRec : postRequestHeadersRec, }, - }, - input.context.config.requestInputOptions, + { + headers: input.context.config.transport.config.headers, + signal: input.context.config.transport.config.signal, + }, + ), + input.context.config.transport.config.raw, ), { headers: input.headers, }, - ), - } - return { - ...input, - request, + ) + const request: CoreExchangePostRequest | CoreExchangeGetRequest = requestMethod === `get` + ? { + methodMode: methodMode as MethodModeGetReads, + ...baseProperties, + method: `get`, + url: searchParamsAppendAll(input.url, slots.searchParams(input)), + } + : { + methodMode: methodMode, + ...baseProperties, + method: `post`, + url: input.url, + body: slots.body(input), + } + return { + ...input, + request, + } } + default: + throw casesExhausted(input) } - default: - throw casesExhausted(input) - } + }, }, exchange: { slots: { - fetch: (request) => { - return fetch(request) - }, + // Put fetch behind a lambda so that it can be easily globally overridden + // by fixtures. + fetch: (request) => fetch(request), }, run: async ({ input, slots }) => { switch (input.transport) { case `http`: { const request = new Request(input.request.url, input.request) - // console.log(request) const response = await slots.fetch(request) - // console.log(response) return { ...input, response, diff --git a/src/layers/6_client/Settings/Config.ts b/src/layers/6_client/Settings/Config.ts index 653e2cc46..e4c47fc5a 100644 --- a/src/layers/6_client/Settings/Config.ts +++ b/src/layers/6_client/Settings/Config.ts @@ -1,14 +1,20 @@ import type { GraphQLError } from 'graphql' import type { Simplify } from 'type-fest' import type { GraphQLExecutionResultError } from '../../../lib/graphql.js' -import type { ConfigManager, SimplifyExceptError, StringKeyof, Values } from '../../../lib/prelude.js' +import type { + ConfigManager, + RequireProperties, + SimplifyExceptError, + StringKeyof, + Values, +} from '../../../lib/prelude.js' import type { Schema } from '../../1_Schema/__.js' import type { GlobalRegistry } from '../../2_generator/globalRegistry.js' import type { SelectionSet } from '../../3_SelectionSet/__.js' import type { Transport } from '../../5_core/types.js' import type { ErrorsOther } from '../client.js' +import type { TransportHttpInput } from '../transportHttp/request.js' import type { InputStatic } from './Input.js' -import type { RequestInputOptions } from './inputIncrementable/request.js' export type OutputChannel = 'throw' | 'return' @@ -111,8 +117,10 @@ export type Config = { initialInput: InputStatic // InputStatic name: GlobalRegistry.SchemaNames output: OutputConfig - transport: Transport - requestInputOptions?: RequestInputOptions + transport: { + type: Transport + config: RequireProperties + } } // dprint-ignore @@ -162,7 +170,7 @@ export type Envelope<$Config extends Config, $Data = unknown, $Errors extends Re extensions?: ObjMap } & ( - $Config['transport'] extends 'http' + $Config['transport']['type'] extends 'http' ? { response: Response } : {} // eslint-disable-line ) diff --git a/src/layers/6_client/Settings/InputToConfig.ts b/src/layers/6_client/Settings/InputToConfig.ts index 12fd15157..3b6c49a53 100644 --- a/src/layers/6_client/Settings/InputToConfig.ts +++ b/src/layers/6_client/Settings/InputToConfig.ts @@ -1,15 +1,18 @@ import type { ConfigManager } from '../../../lib/prelude.js' import type { GlobalRegistry } from '../../2_generator/globalRegistry.js' import { Transport, type TransportHttp, type TransportMemory } from '../../5_core/types.js' -import { outputConfigDefault } from './Config.js' +import { defaultMethodMode } from '../transportHttp/request.js' +import { type Config, outputConfigDefault } from './Config.js' import type { InputOutputEnvelopeLonghand, InputStatic, URLInput } from './Input.js' -import type { RequestInputOptions } from './inputIncrementable/request.js' // dprint-ignore export type InputToConfig<$Input extends InputStatic> = { initialInput: $Input name: HandleName<$Input> - transport: HandleTransport<$Input> + transport: { + type: HandleTransport<$Input> + config: Config['transport']['config'] + } output: { defaults: { errorChannel: ConfigManager.ReadOrDefault<$Input, ['output', 'defaults', 'errorChannel'], 'throw'> @@ -32,7 +35,6 @@ export type InputToConfig<$Input extends InputStatic schema: ConfigManager.ReadOrDefault<$Input,['output', 'errors', 'schema'], false> } } - requestInputOptions?: RequestInputOptions } export const defaultSchemaName: GlobalRegistry.DefaultSchemaName = `default` @@ -49,8 +51,13 @@ export const inputToConfig = <$Input extends InputStatic> = ? TransportHttp : TransportMemory -const handleTransport = >(input: T): HandleTransport => { +const handleTransportType = >(input: T): HandleTransport => { // @ts-expect-error conditional type return input.schema instanceof URL || typeof input.schema === `string` ? Transport.http : Transport.memory } diff --git a/src/layers/6_client/Settings/inputIncrementable/inputIncrementable.ts b/src/layers/6_client/Settings/inputIncrementable/inputIncrementable.ts index 795870b08..bc8f9e8c8 100644 --- a/src/layers/6_client/Settings/inputIncrementable/inputIncrementable.ts +++ b/src/layers/6_client/Settings/inputIncrementable/inputIncrementable.ts @@ -1,9 +1,9 @@ import type { GlobalRegistry } from '../../../2_generator/globalRegistry.js' import type { Transport, TransportMemory } from '../../../5_core/types.js' +import type { TransportHttpInput } from '../../transportHttp/request.js' import type { Config } from '../Config.js' import type { InputToConfig } from '../InputToConfig.js' import type { OutputInput } from './output.js' -import type { RequestInputOptions } from './request.js' // dprint-ignore export type InputIncrementable<$Context extends IncrementableInputContext = IncrementableInputContext> = @@ -15,10 +15,9 @@ export type InputIncrementable<$Context extends IncrementableInputContext = Incr } & ( $Context['transport'] extends TransportMemory - ? { request?: never } - : { request?: RequestInputOptions } + ? { transport?: never } + : { transport?: TransportHttpInput } ) -// type x = (never|{}) & {x:1} export type IncrementableInputContext = { name: GlobalRegistry.SchemaNames diff --git a/src/layers/6_client/Settings/inputIncrementable/request.ts b/src/layers/6_client/Settings/inputIncrementable/request.ts deleted file mode 100644 index 631cca0e9..000000000 --- a/src/layers/6_client/Settings/inputIncrementable/request.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { mergeHeadersInit } from '../../../../lib/http.js' - -/** - * An extension of {@link RequestInit} that adds a required `url` property and makes `body` required. - */ -export type RequestInput = RequestInputOptions & { - url: string | URL - method: HttpMethodInput - body: BodyInit -} - -/** - * A variant of {@link RequestInit} that removes `body` and strongly types `method`. - */ -export type RequestInputOptions = Omit & { - method?: HttpMethodInput -} - -type HttpMethodInput = - | 'get' - | 'post' - | 'put' - | 'delete' - | 'patch' - | 'head' - | 'options' - | 'trace' - | 'GET' - | 'POST' - | 'PUT' - | 'DELETE' - | 'PATCH' - | 'HEAD' - | 'OPTIONS' - | 'TRACE' - -export const mergeRequestInputOptions = (a?: RequestInputOptions, b?: RequestInputOptions): RequestInputOptions => { - const headers = mergeHeadersInit(a?.headers ?? {}, b?.headers ?? {}) - return { - ...a, - ...b, - headers, - } -} diff --git a/src/layers/6_client/client.transport-http.test.ts b/src/layers/6_client/client.transport-http.test.ts index e7a3eb0d2..c97ce4e04 100644 --- a/src/layers/6_client/client.transport-http.test.ts +++ b/src/layers/6_client/client.transport-http.test.ts @@ -3,18 +3,19 @@ import { createResponse, test } from '../../../tests/_/helpers.js' import { Graffle } from '../../entrypoints/main.js' import { ACCEPT_REC, CONTENT_TYPE_REC } from '../../lib/graphqlHTTP.js' import { Transport } from '../5_core/types.js' -import type { RequestInput } from './Settings/inputIncrementable/request.js' +import type { CoreExchangeGetRequest, CoreExchangePostRequest } from './transportHttp/request.js' -const endpoint = new URL(`https://foo.io/api/graphql`) +const schema = new URL(`https://foo.io/api/graphql`) test(`anyware hooks are typed to http transport`, () => { - Graffle.create({ schema: endpoint }).use(async ({ encode }) => { + Graffle.create({ schema }).use(async ({ encode }) => { expectTypeOf(encode.input.transport).toEqualTypeOf(Transport.http) const { pack } = await encode() expectTypeOf(pack.input.transport).toEqualTypeOf(Transport.http) const { exchange } = await pack() expectTypeOf(exchange.input.transport).toEqualTypeOf(Transport.http) - expectTypeOf(exchange.input.request).toEqualTypeOf() + // todo we can statically track the method mode like we do the transport mode + expectTypeOf(exchange.input.request).toEqualTypeOf() const { unpack } = await exchange() expectTypeOf(unpack.input.transport).toEqualTypeOf(Transport.http) expectTypeOf(unpack.input.response).toEqualTypeOf() @@ -29,48 +30,91 @@ test(`anyware hooks are typed to http transport`, () => { }) }) -test(`can set headers in constructor`, async ({ fetch }) => { - fetch.mockImplementationOnce(() => Promise.resolve(createResponse({ data: { id: `abc` } }))) - const graffle = Graffle.create({ schema: endpoint, request: { headers: { 'x-foo': `bar` } } }) - await graffle.rawString({ document: `query { id }` }) - const request = fetch.mock.calls[0]?.[0] - expect(request?.headers.get(`x-foo`)).toEqual(`bar`) +describe(`methodMode`, () => { + describe(`default (post)`, () => { + test(`sends spec compliant post request by default`, async ({ fetch, graffle }) => { + fetch.mockImplementationOnce(() => Promise.resolve(createResponse({ data: { id: `abc` } }))) + await graffle.rawString({ document: `query { id }` }) + const request = fetch.mock.calls[0]?.[0] + expect(request?.method).toEqual(`POST`) + expect(request?.headers.get(`content-type`)).toEqual(CONTENT_TYPE_REC) + expect(request?.headers.get(`accept`)).toEqual(ACCEPT_REC) + }) + }) + describe(`get`, () => { + test(`can set method mode to get`, async ({ fetch }) => { + fetch.mockImplementationOnce(() => Promise.resolve(createResponse({ data: { user: { name: `foo` } } }))) + const graffle = Graffle.create({ schema, transport: { methodMode: `getReads` } }) + await graffle.rawString({ + document: `query foo($id: ID!){user(id:$id){name}}`, + variables: { 'id': `QVBJcy5ndXJ1` }, + operationName: `foo`, + }) + const request = fetch.mock.calls[0]?.[0] + expect(request?.method).toEqual(`GET`) + expect(request?.headers.get(`content-type`)).toEqual(null) + expect(request?.headers.get(`accept`)).toEqual(ACCEPT_REC) + expect(request?.url).toMatchInlineSnapshot( + `"https://foo.io/api/graphql?query=query+foo%28%24id%3A+ID%21%29%7Buser%28id%3A%24id%29%7Bname%7D%7D&variables=%7B%22id%22%3A%22QVBJcy5ndXJ1%22%7D&operationName=foo"`, + ) + }) + test(`if no variables or operationName then search parameters are omitted`, async ({ fetch }) => { + fetch.mockImplementationOnce(() => Promise.resolve(createResponse({ data: { user: { name: `foo` } } }))) + const graffle = Graffle.create({ schema, transport: { methodMode: `getReads` } }) + await graffle.rawString({ document: `query {user{name}}` }) + const request = fetch.mock.calls[0]?.[0] + expect(request?.url).toMatchInlineSnapshot(`"https://foo.io/api/graphql?query=query+%7Buser%7Bname%7D%7D"`) + }) + test(`mutation still uses POST`, async ({ fetch }) => { + fetch.mockImplementationOnce(() => Promise.resolve(createResponse({ data: { user: { name: `foo` } } }))) + const graffle = Graffle.create({ schema, transport: { methodMode: `getReads` } }) + await graffle.rawString({ document: `mutation { user { name } }` }) + const request = fetch.mock.calls[0]?.[0] + expect(request?.method).toEqual(`POST`) + expect(request?.headers.get(`content-type`)).toEqual(CONTENT_TYPE_REC) + expect(request?.headers.get(`accept`)).toEqual(ACCEPT_REC) + }) + }) }) -test(`sends spec compliant request`, async ({ fetch, graffle }) => { - fetch.mockImplementationOnce(() => Promise.resolve(createResponse({ data: { greetings: `Hello World` } }))) - await graffle.rawString({ document: `query { greetings }` }) - const request = fetch.mock.calls[0]?.[0] - expect(request?.headers.get(`content-type`)).toEqual(CONTENT_TYPE_REC) - expect(request?.headers.get(`accept`)).toEqual(ACCEPT_REC) -}) +describe(`configuration`, () => { + test(`can set headers`, async ({ fetch }) => { + fetch.mockImplementationOnce(() => Promise.resolve(createResponse({ data: { id: `abc` } }))) + const graffle = Graffle.create({ schema, transport: { headers: { 'x-foo': `bar` } } }) + await graffle.rawString({ document: `query { id }` }) + const request = fetch.mock.calls[0]?.[0] + expect(request?.headers.get(`x-foo`)).toEqual(`bar`) + }) -describe(`signal`, () => { - // JSDom and Node result in different errors. JSDom is a plain Error type. Presumably an artifact of JSDom and now in actual browsers. - const abortErrorMessagePattern = /This operation was aborted|AbortError: The operation was aborted/ - test(`AbortController at instance level works`, async () => { - const abortController = new AbortController() - const graffle = Graffle.create({ - schema: endpoint, - request: { signal: abortController.signal }, - }) - const resultPromise = graffle.rawString({ document: `query { id }` }) - abortController.abort() - const { caughtError } = await resultPromise.catch((caughtError: unknown) => ({ caughtError })) as any as { - caughtError: Error - } - expect(caughtError.message).toMatch(abortErrorMessagePattern) + test(`can set raw (requestInit)`, async ({ fetch }) => { + fetch.mockImplementationOnce(() => Promise.resolve(createResponse({ data: { id: `abc` } }))) + const graffle = Graffle.create({ schema, transport: { raw: { headers: { 'x-foo': `bar` } } } }) + await graffle.rawString({ document: `query { id }` }) + const request = fetch.mock.calls[0]?.[0] + expect(request?.headers.get(`x-foo`)).toEqual(`bar`) }) - test(`AbortController at method level works`, async () => { - const abortController = new AbortController() - const graffle = Graffle.create({ - schema: endpoint, - }).with({ request: { signal: abortController.signal } }) - const resultPromise = graffle.rawString({ document: `query { id }` }) - abortController.abort() - const { caughtError } = await resultPromise.catch((caughtError: unknown) => ({ caughtError })) as any as { - caughtError: Error - } - expect(caughtError.message).toMatch(abortErrorMessagePattern) + describe(`can set signal`, () => { + // JSDom and Node result in different errors. JSDom is a plain Error type. Presumably an artifact of JSDom and now in actual browsers. + const abortErrorMessagePattern = /This operation was aborted|AbortError: The operation was aborted/ + test(`to constructor`, async () => { + const abortController = new AbortController() + const graffle = Graffle.create({ schema, transport: { signal: abortController.signal } }) + const resultPromise = graffle.rawString({ document: `query { id }` }) + abortController.abort() + const { caughtError } = await resultPromise.catch((caughtError: unknown) => ({ caughtError })) as any as { + caughtError: Error + } + expect(caughtError.message).toMatch(abortErrorMessagePattern) + }) + test(`to "with"`, async () => { + const abortController = new AbortController() + const graffle = Graffle.create({ schema }).with({ transport: { signal: abortController.signal } }) + const resultPromise = graffle.rawString({ document: `query { id }` }) + abortController.abort() + const { caughtError } = await resultPromise.catch((caughtError: unknown) => ({ caughtError })) as any as { + caughtError: Error + } + expect(caughtError.message).toMatch(abortErrorMessagePattern) + }) }) }) diff --git a/src/layers/6_client/client.transport-memory.test.ts b/src/layers/6_client/client.transport-memory.test.ts index 21e1c07d5..3e8a88650 100644 --- a/src/layers/6_client/client.transport-memory.test.ts +++ b/src/layers/6_client/client.transport-memory.test.ts @@ -33,5 +33,5 @@ test(`anyware hooks are typed to memory transport`, () => { test(`cannot set headers in constructor`, () => { // todo: This error is poor for the user. It refers to schema not being a URL. The better message would be that headers is not allowed with memory transport. // @ts-expect-error headers not allowed with GraphQL schema - Graffle.create({ schema, request: { headers: { 'x-foo': `bar` } } }) + Graffle.create({ schema, transport: { headers: { 'x-foo': `bar` } } }) }) diff --git a/src/layers/6_client/client.ts b/src/layers/6_client/client.ts index 1d9ff5edb..d18d9759a 100644 --- a/src/layers/6_client/client.ts +++ b/src/layers/6_client/client.ts @@ -2,6 +2,7 @@ import { type ExecutionResult, GraphQLSchema, type TypedQueryDocumentNode } from import type { Anyware } from '../../lib/anyware/__.js' import type { Errors } from '../../lib/errors/__.js' import { isOperationTypeName, operationTypeNameToRootTypeName, type RootTypeName } from '../../lib/graphql.js' +import { mergeRequestInit } from '../../lib/http.js' import type { BaseInput, BaseInput_, TypedDocumentString } from '../0_functions/types.js' import { Schema } from '../1_Schema/__.js' import { readMaybeThunk } from '../1_Schema/core/helpers.js' @@ -22,7 +23,6 @@ import { } from './Settings/Config.js' import { type InputStatic } from './Settings/Input.js' import type { AddIncrementalInput, InputIncrementable } from './Settings/inputIncrementable/inputIncrementable.js' -import { mergeRequestInputOptions } from './Settings/inputIncrementable/request.js' import { type InputToConfig, inputToConfig } from './Settings/InputToConfig.js' /** @@ -36,13 +36,13 @@ export type ErrorsOther = export type GraffleExecutionResultVar<$Config extends Config = Config> = | ( & ExecutionResult - & ($Config['transport'] extends TransportHttp ? { + & ($Config['transport']['type'] extends TransportHttp ? { /** * If transport was HTTP, then the raw response is available here. */ response: Response } - : TransportHttp extends $Config['transport'] ? { + : TransportHttp extends $Config['transport']['type'] ? { /** * If transport was HTTP, then the raw response is available here. */ @@ -178,7 +178,7 @@ const create_ = ( schema: state.input.schema, context: { config: context.config, - transport, + transportInputOptions: state.input.transport, interface: interface_, schemaIndex: context.schemaIndex, }, @@ -313,7 +313,11 @@ const create_ = ( input: { ...state.input, output: state.input.output, - request: mergeRequestInputOptions(state.input.request, input.request), + transport: { + ...state.input.transport, + ...input.transport, + raw: mergeRequestInit(state.input.transport?.raw, input.transport?.raw), + }, }, }) }, diff --git a/src/layers/6_client/handleOutput.ts b/src/layers/6_client/handleOutput.ts index cbaff22f1..55ee2698c 100644 --- a/src/layers/6_client/handleOutput.ts +++ b/src/layers/6_client/handleOutput.ts @@ -10,7 +10,7 @@ export const handleOutput = ( ) => { // If core errors caused by an abort error then raise it as a direct error. // This is an expected possible error. Possible when user cancels a request. - if (context.config.transport === Transport.http && result instanceof Error && isAbortError(result.cause)) { + if (context.config.transport.type === Transport.http && result instanceof Error && isAbortError(result.cause)) { result = result.cause } diff --git a/src/layers/6_client/transportHttp/request.ts b/src/layers/6_client/transportHttp/request.ts new file mode 100644 index 000000000..cffca80d6 --- /dev/null +++ b/src/layers/6_client/transportHttp/request.ts @@ -0,0 +1,48 @@ +import type { httpMethodGet, httpMethodPost } from '../../../lib/http.js' + +export const MethodMode = { + post: `post`, + getReads: `getReads`, +} as const + +export type MethodModeGetReads = typeof MethodMode['getReads'] +export type MethodModePost = typeof MethodMode['post'] +export type MethodMode = MethodModePost | MethodModeGetReads + +export type TransportHttpInput = { + /** + * The HTTP method to use to make the request. + * + * Note that this is not just about the HTTP method but also about how the payload is sent. + * Namely, `get` will send the payload as part of the URL search parameters while `post` will send it as a JSON body. + * + * Options: + * + * 1. `post` - Apply https://graphql.github.io/graphql-over-http/draft/#sec-POST + * 2. `getReads` - Apply https://graphql.github.io/graphql-over-http/draft/#sec-GET + * + * @defaultValue `post` + */ + methodMode?: MethodMode + headers?: HeadersInit + signal?: AbortSignal | null + raw?: RequestInit +} + +/** + * An extension of {@link RequestInit} that adds a required `url` property and makes `body` required. + */ +export type CoreExchangePostRequest = Omit & { + methodMode: MethodModePost | MethodModeGetReads + method: httpMethodPost + url: string | URL // todo URL for config and string only for input. Requires anyware to allow different types for input and existing config. + body: BodyInit +} + +export type CoreExchangeGetRequest = Omit & { + methodMode: MethodModeGetReads + method: httpMethodGet + url: string | URL +} + +export const defaultMethodMode: MethodMode = `post` diff --git a/src/layers/7_extensions/Upload/Upload.test.ts b/src/layers/7_extensions/Upload/Upload.test.ts index 8e31edaa2..9aba2ccef 100644 --- a/src/layers/7_extensions/Upload/Upload.test.ts +++ b/src/layers/7_extensions/Upload/Upload.test.ts @@ -2,50 +2,36 @@ // @vitest-environment node import { omit } from 'es-toolkit' -import getPort from 'get-port' -import type { Server } from 'node:http' -import { createServer } from 'node:http' import { afterAll, beforeAll, beforeEach, expect, test } from 'vitest' import { schema } from '../../../../tests/_/schemaUpload/schema.js' import { Graffle } from '../../../entrypoints/main.js' import { Upload } from './Upload.js' -import { createYoga } from 'graphql-yoga' +import { type SchemaServer, serveSchema } from '../../../../examples/$/helpers.js' import type { Client } from '../../6_client/client.js' -import type { OutputConfigDefault } from '../../6_client/Settings/Config.js' +import type { Config, OutputConfigDefault } from '../../6_client/Settings/Config.js' -let server: Server -let port: number +let schemaServer: SchemaServer let graffle: Client< any, - { transport: 'http'; output: OutputConfigDefault; initialInput: { schema: URL }; name: 'default' } + { + transport: { type: 'http'; config: Config['transport']['config'] } + output: OutputConfigDefault + initialInput: { schema: URL } + name: 'default' + } > beforeAll(async () => { - const yoga = createYoga({ schema }) - server = createServer(yoga) // eslint-disable-line - port = await getPort({ port: [3000, 3001, 3002, 3003, 3004] }) - server.listen(port) - await new Promise((resolve) => - server.once(`listening`, () => { - resolve(undefined) - }) - ) + schemaServer = await serveSchema({ schema }) }) beforeEach(() => { - graffle = Graffle.create({ - schema: new URL(`http://localhost:${String(port)}/graphql`), - }).use(Upload) + graffle = Graffle.create({ schema: schemaServer.url }).use(Upload) }) afterAll(async () => { - await new Promise((resolve) => { - server.close(resolve) - setImmediate(() => { - server.emit(`close`) - }) - }) + await schemaServer.stop() }) test(`upload`, async () => { diff --git a/src/layers/7_extensions/Upload/Upload.ts b/src/layers/7_extensions/Upload/Upload.ts index 81571265f..00cdc185a 100644 --- a/src/layers/7_extensions/Upload/Upload.ts +++ b/src/layers/7_extensions/Upload/Upload.ts @@ -7,15 +7,36 @@ import { createBody } from './createBody.js' */ export const Upload = createExtension({ name: `Upload`, - anyware: async ({ encode }) => { - const { pack } = await encode({ + anyware: async ({ pack }) => { + // const { pack } = await encode({ + // using: { + // body: (input) => { + // const hasUploadScalarVariable = input.variables && isUsingUploadScalar(input.variables) + // if (!hasUploadScalarVariable) return + + // // TODO we can probably get file upload working for in-memory schemas too :) + // if (encode.input.transport !== `http`) throw new Error(`Must be using http transport to use "Upload" scalar.`) + + // return createBody({ + // query: input.query, + // variables: input.variables!, + // }) + // }, + // }, + // }) + // Remove the content-type header so that fetch sets it automatically upon seeing the body is a FormData instance. + // @see https://muffinman.io/blog/uploading-files-using-fetch-multipart-form-data/ + // @see https://stackoverflow.com/questions/3508338/what-is-the-boundary-in-multipart-form-data + // @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition + return await pack({ + // todo rename "using" to "with" using: { body: (input) => { const hasUploadScalarVariable = input.variables && isUsingUploadScalar(input.variables) if (!hasUploadScalarVariable) return // TODO we can probably get file upload working for in-memory schemas too :) - if (encode.input.transport !== `http`) throw new Error(`Must be using http transport to use "Upload" scalar.`) + if (pack.input.transport !== `http`) throw new Error(`Must be using http transport to use "Upload" scalar.`) return createBody({ query: input.query, @@ -23,12 +44,6 @@ export const Upload = createExtension({ }) }, }, - }) - // Remove the content-type header so that fetch sets it automatically upon seeing the body is a FormData instance. - // @see https://muffinman.io/blog/uploading-files-using-fetch-multipart-form-data/ - // @see https://stackoverflow.com/questions/3508338/what-is-the-boundary-in-multipart-form-data - // @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition - return await pack({ input: { ...pack.input, headers: { diff --git a/src/lib/graphql.test.ts b/src/lib/graphql.test.ts new file mode 100644 index 000000000..bad775396 --- /dev/null +++ b/src/lib/graphql.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from 'vitest' +import { type GraphQLRequestEncoded, type OperationTypeNameAll, parseGraphQLOperationType } from './graphql.js' + +const operationNameOne = `one` +const operationNameTwo = `two` +const docNoDefinedOps = `` +const docMultipleQueryOperations = `query ${operationNameOne} { x }\nquery ${operationNameTwo} { x }` +const docMultipleMixedOperations = `mutation ${operationNameOne} { x }\nquery ${operationNameTwo} { x }` +const docOverloadedTerms = `query { queryX }` + +type CaseParameters = [ + description: string, + request: GraphQLRequestEncoded, + result: null | OperationTypeNameAll, +] + +describe(`parseGraphQLOperationType`, () => { + // dprint-ignore + test.each([ + + [ `null if no defined operations and operation name given `, { query: docNoDefinedOps, operationName: operationNameOne }, null ], + [ `null if multiple defined operations and no operation name given`, { query: docMultipleQueryOperations }, null ], + [ `null if multiple defined operations and no operation name given (empty string)`, { query: docMultipleQueryOperations, operationName: `` }, null ], + [ `null if multiple defined operations and operation name given not found`, { query: docMultipleQueryOperations, operationName: `foo` }, null ], + [ `assume query if no defined operations and no operation name given `, { query: docNoDefinedOps }, `query` ], + [ `query if multiple defined query operations and no query operation name given `, { query: docMultipleQueryOperations, operationName: operationNameOne }, `query` ], + [ `query if multiple defined mixed operations and no mutation operation name given `, { query: docMultipleMixedOperations, operationName: operationNameTwo }, `query` ], + [ `mutation if multiple defined mixed operations and no query operation name given `, { query: docMultipleMixedOperations, operationName: operationNameOne }, `mutation` ], + [ `mutation if only operation without name and no operation given `, { query: `mutation { user { name } }` }, `mutation` ], + [ `overloaded terms do not confuse parser`, { query: docOverloadedTerms }, `query` ], + ])(`%s`, (_, request, result) => { + expect(parseGraphQLOperationType(request)).toEqual(result) + }) +}) diff --git a/src/lib/graphql.ts b/src/lib/graphql.ts index 9be85c0de..16a37afc0 100644 --- a/src/lib/graphql.ts +++ b/src/lib/graphql.ts @@ -1,4 +1,11 @@ -import type { GraphQLEnumValue, GraphQLError, GraphQLField, GraphQLInputField, GraphQLSchema } from 'graphql' +import type { + DocumentNode, + GraphQLEnumValue, + GraphQLError, + GraphQLField, + GraphQLInputField, + GraphQLSchema, +} from 'graphql' import { GraphQLEnumType, GraphQLInputObjectType, @@ -248,7 +255,79 @@ export type StandardScalarVariables = { export type GraphQLExecutionResultError = Errors.ContextualAggregateError -export type OperationTypeName = 'query' | 'mutation' +export const OperationTypes = { + query: `query`, + mutation: `mutation`, + subscription: `subscription`, +} as const + +type OperationTypeQuery = typeof OperationTypes['query'] +type OperationTypeMutation = typeof OperationTypes['mutation'] +type OperationTypeSubscription = typeof OperationTypes['subscription'] + +export type OperationTypeName = OperationTypeQuery | OperationTypeMutation +export type OperationTypeNameAll = OperationTypeName | OperationTypeSubscription + +export const OperationTypeAccessTypeMap = { + query: `read`, + mutation: `write`, + subscription: `read`, +} as const export const isOperationTypeName = (value: unknown): value is OperationTypeName => value === `query` || value === `mutation` + +export type GraphQLRequestEncoded = { + query: string + variables?: StandardScalarVariables + operationName?: string +} + +export type GraphQLRequestInput = { + document: string | DocumentNode + variables?: StandardScalarVariables + operationName?: string +} + +const definedOperationPattern = new RegExp(`^\\b(${Object.values(OperationTypes).join(`|`)})\\b`) + +export const parseGraphQLOperationType = (request: GraphQLRequestEncoded): OperationTypeNameAll | null => { + const { operationName, query: document } = request + + const definedOperations = document.split(/[{}\n]+/).map(s => s.trim()).map(line => { + const match = line.match(definedOperationPattern) + if (!match) return null + return { + line, + operationType: match[0] as OperationTypeNameAll, + } + }).filter(Boolean) + + // Handle obviously invalid cases that are zero cost to compute. + + // The given operation name will not match to anything. + if (definedOperations.length > 1 && !request.operationName) return null + + // An operation name is required but was not given. + if (definedOperations.length === 0 && request.operationName) return null + + // Handle optimistically assumed valid case short circuits. + + if (definedOperations.length === 0) { + // Assume that the implicit query syntax is being used. + // This is a non-validated optimistic approach for performance, not aimed at correctness. + // For example its not checked if the document is actually of the syntactic form `{ ... }` + return OperationTypes.query + } + + // Continue to the full search. + + const definedOperationToAnalyze = operationName + ? definedOperations.find(o => o?.line.includes(operationName)) + : definedOperations[0] + + // Invalid: The given operation name does not show up in the document. + if (!definedOperationToAnalyze) return null + + return definedOperationToAnalyze.operationType +} diff --git a/src/lib/graphqlHTTP.ts b/src/lib/graphqlHTTP.ts index af58a03b7..e5cb21330 100644 --- a/src/lib/graphqlHTTP.ts +++ b/src/lib/graphqlHTTP.ts @@ -1,6 +1,6 @@ import type { GraphQLFormattedError } from 'graphql' import { type ExecutionResult, GraphQLError } from 'graphql' -import type { StandardScalarVariables } from './graphql.js' +import type { GraphQLRequestEncoded, StandardScalarVariables } from './graphql.js' import { CONTENT_TYPE_GQL, CONTENT_TYPE_JSON } from './http.js' import { isPlainObject } from './prelude.js' @@ -63,3 +63,31 @@ export const CONTENT_TYPE_REC = CONTENT_TYPE_JSON * @see https://graphql.github.io/graphql-over-http/draft/#sec-Legacy-Watershed */ export const ACCEPT_REC = `${CONTENT_TYPE_GQL}; charset=utf-8, ${CONTENT_TYPE_JSON}; charset=utf-8` + +export const postRequestHeadersRec = { + accept: ACCEPT_REC, + 'content-type': CONTENT_TYPE_REC, +} + +export const getRequestHeadersRec = { + accept: ACCEPT_REC, +} + +export const getRequestEncodeSearchParameters = (request: GraphQLRequestEncoded): Record => { + return { + query: request.query, + ...(request.variables ? { variables: JSON.stringify(request.variables) } : {}), + ...(request.operationName ? { operationName: request.operationName } : {}), + } +} +export type getRequestEncodeSearchParameters = typeof getRequestEncodeSearchParameters + +export const postRequestEncodeBody = (input: GraphQLRequestEncoded): BodyInit => { + return JSON.stringify({ + query: input.query, + variables: input.variables, + operationName: input.operationName, + }) +} + +export type postRequestEncodeBody = typeof postRequestEncodeBody diff --git a/src/lib/http.ts b/src/lib/http.ts index 08bb05b4b..1296d738f 100644 --- a/src/lib/http.ts +++ b/src/lib/http.ts @@ -22,3 +22,49 @@ export const mergeHeadersInit = (headers?: HeadersInit, additionalHeaders?: Head } const UnsetValue = `` + +export type httpMethodGet = 'get' + +export type httpMethodPost = 'post' + +export type HttpMethodInput = + | 'get' + | 'post' + | 'put' + | 'delete' + | 'patch' + | 'head' + | 'options' + | 'trace' + | 'GET' + | 'POST' + | 'PUT' + | 'DELETE' + | 'PATCH' + | 'HEAD' + | 'OPTIONS' + | 'TRACE' + +export const mergeRequestInit = (a?: RequestInit, b?: RequestInit): RequestInit => { + const headers = mergeHeadersInit(a?.headers ?? {}, b?.headers ?? {}) + return { + ...a, + ...b, + headers, + } +} + +export type SearchParamsInit = ConstructorParameters[0] + +export const searchParamsAppendAllMutate = (url: URL, additionalSearchParams: SearchParamsInit) => { + const sp = new URLSearchParams(additionalSearchParams) + sp.forEach((value, key) => { + url.searchParams.append(key, value) + }) +} + +export const searchParamsAppendAll = (url: URL | string, additionalSearchParams: SearchParamsInit) => { + const url2 = new URL(url) + searchParamsAppendAllMutate(url2, additionalSearchParams) + return url2 +} diff --git a/src/lib/prelude.ts b/src/lib/prelude.ts index 514726f6d..ce6a247e1 100644 --- a/src/lib/prelude.ts +++ b/src/lib/prelude.ts @@ -397,3 +397,10 @@ export type PickRequiredProperties = { export type Negate = T extends true ? false : true export type SimplifyExceptError = ConditionalSimplify + +export type RequireProperties = Simplify + +export const throwNull = (value: V): Exclude => { + if (value === null) throw new Error('Unexpected null value.') + return value as Exclude +} diff --git a/tests/examples/transport-http_fetch.test.ts b/tests/examples/transport-http|transport-http_RequestInput.test.ts similarity index 59% rename from tests/examples/transport-http_fetch.test.ts rename to tests/examples/transport-http|transport-http_RequestInput.test.ts index 7ce07e80a..87174671d 100644 --- a/tests/examples/transport-http_fetch.test.ts +++ b/tests/examples/transport-http|transport-http_RequestInput.test.ts @@ -7,13 +7,16 @@ import { execaCommand } from 'execa' import stripAnsi from 'strip-ansi' import { expect, test } from 'vitest' +import { encode } from '../../examples/transport-http_headers__dynamicHeaders.output-encoder.js' -test(`transport-http_fetch`, async () => { - const result = await execaCommand(`pnpm tsx ./examples/transport-http_fetch.ts`) +test(`transport-http|transport-http_RequestInput`, async () => { + const result = await execaCommand(`pnpm tsx ./examples/transport-http|transport-http_RequestInput.ts`) expect(result.exitCode).toBe(0) // Examples should output their data results. - const exampleResult = stripAnsi(result.stdout) + const exampleResult = encode(stripAnsi(result.stdout)) // If ever outputs vary by Node version, you can use this to snapshot by Node version. // const nodeMajor = process.version.match(/v(\d+)/)?.[1] ?? `unknown` - await expect(exampleResult).toMatchFileSnapshot(`../.././examples/transport-http_fetch.output.txt`) + await expect(exampleResult).toMatchFileSnapshot( + `../.././examples/transport-http|transport-http_RequestInput.output.test.txt`, + ) }) diff --git a/tests/examples/transport-http_headers__dynamicHeaders.test.ts b/tests/examples/transport-http|transport-http_abort.test.ts similarity index 76% rename from tests/examples/transport-http_headers__dynamicHeaders.test.ts rename to tests/examples/transport-http|transport-http_abort.test.ts index b1cd180f3..8179f24b0 100644 --- a/tests/examples/transport-http_headers__dynamicHeaders.test.ts +++ b/tests/examples/transport-http|transport-http_abort.test.ts @@ -9,12 +9,14 @@ import stripAnsi from 'strip-ansi' import { expect, test } from 'vitest' import { encode } from '../../examples/transport-http_headers__dynamicHeaders.output-encoder.js' -test(`transport-http_headers__dynamicHeaders`, async () => { - const result = await execaCommand(`pnpm tsx ./examples/transport-http_headers__dynamicHeaders.ts`) +test(`transport-http|transport-http_abort`, async () => { + const result = await execaCommand(`pnpm tsx ./examples/transport-http|transport-http_abort.ts`) expect(result.exitCode).toBe(0) // Examples should output their data results. const exampleResult = encode(stripAnsi(result.stdout)) // If ever outputs vary by Node version, you can use this to snapshot by Node version. // const nodeMajor = process.version.match(/v(\d+)/)?.[1] ?? `unknown` - await expect(exampleResult).toMatchFileSnapshot(`../.././examples/transport-http_headers__dynamicHeaders.output.txt`) + await expect(exampleResult).toMatchFileSnapshot( + `../.././examples/transport-http|transport-http_abort.output.test.txt`, + ) }) diff --git a/tests/examples/transport-http_abort.test.ts b/tests/examples/transport-http|transport-http_fetch.test.ts similarity index 60% rename from tests/examples/transport-http_abort.test.ts rename to tests/examples/transport-http|transport-http_fetch.test.ts index 8d9915487..83dc9e6af 100644 --- a/tests/examples/transport-http_abort.test.ts +++ b/tests/examples/transport-http|transport-http_fetch.test.ts @@ -7,13 +7,16 @@ import { execaCommand } from 'execa' import stripAnsi from 'strip-ansi' import { expect, test } from 'vitest' +import { encode } from '../../examples/transport-http_headers__dynamicHeaders.output-encoder.js' -test(`transport-http_abort`, async () => { - const result = await execaCommand(`pnpm tsx ./examples/transport-http_abort.ts`) +test(`transport-http|transport-http_fetch`, async () => { + const result = await execaCommand(`pnpm tsx ./examples/transport-http|transport-http_fetch.ts`) expect(result.exitCode).toBe(0) // Examples should output their data results. - const exampleResult = stripAnsi(result.stdout) + const exampleResult = encode(stripAnsi(result.stdout)) // If ever outputs vary by Node version, you can use this to snapshot by Node version. // const nodeMajor = process.version.match(/v(\d+)/)?.[1] ?? `unknown` - await expect(exampleResult).toMatchFileSnapshot(`../.././examples/transport-http_abort.output.txt`) + await expect(exampleResult).toMatchFileSnapshot( + `../.././examples/transport-http|transport-http_fetch.output.test.txt`, + ) }) diff --git a/tests/examples/transport-http|transport-http_headers__dynamicHeaders.test.ts b/tests/examples/transport-http|transport-http_headers__dynamicHeaders.test.ts new file mode 100644 index 000000000..09c4c45e2 --- /dev/null +++ b/tests/examples/transport-http|transport-http_headers__dynamicHeaders.test.ts @@ -0,0 +1,22 @@ +// @vitest-environment node + +// WARNING: +// This test is generated by scripts/generate-example-derivatives/generate.ts +// Do not modify this file directly. + +import { execaCommand } from 'execa' +import stripAnsi from 'strip-ansi' +import { expect, test } from 'vitest' +import { encode } from '../../examples/transport-http_headers__dynamicHeaders.output-encoder.js' + +test(`transport-http|transport-http_headers__dynamicHeaders`, async () => { + const result = await execaCommand(`pnpm tsx ./examples/transport-http|transport-http_headers__dynamicHeaders.ts`) + expect(result.exitCode).toBe(0) + // Examples should output their data results. + const exampleResult = encode(stripAnsi(result.stdout)) + // If ever outputs vary by Node version, you can use this to snapshot by Node version. + // const nodeMajor = process.version.match(/v(\d+)/)?.[1] ?? `unknown` + await expect(exampleResult).toMatchFileSnapshot( + `../.././examples/transport-http|transport-http_headers__dynamicHeaders.output.test.txt`, + ) +}) diff --git a/tests/examples/transport-http_RequestInput.test.ts b/tests/examples/transport-http|transport-http_method-get.test.ts similarity index 59% rename from tests/examples/transport-http_RequestInput.test.ts rename to tests/examples/transport-http|transport-http_method-get.test.ts index d991d8560..2c059ad3f 100644 --- a/tests/examples/transport-http_RequestInput.test.ts +++ b/tests/examples/transport-http|transport-http_method-get.test.ts @@ -7,13 +7,16 @@ import { execaCommand } from 'execa' import stripAnsi from 'strip-ansi' import { expect, test } from 'vitest' +import { encode } from '../../examples/transport-http_headers__dynamicHeaders.output-encoder.js' -test(`transport-http_RequestInput`, async () => { - const result = await execaCommand(`pnpm tsx ./examples/transport-http_RequestInput.ts`) +test(`transport-http|transport-http_method-get`, async () => { + const result = await execaCommand(`pnpm tsx ./examples/transport-http|transport-http_method-get.ts`) expect(result.exitCode).toBe(0) // Examples should output their data results. - const exampleResult = stripAnsi(result.stdout) + const exampleResult = encode(stripAnsi(result.stdout)) // If ever outputs vary by Node version, you can use this to snapshot by Node version. // const nodeMajor = process.version.match(/v(\d+)/)?.[1] ?? `unknown` - await expect(exampleResult).toMatchFileSnapshot(`../.././examples/transport-http_RequestInput.output.txt`) + await expect(exampleResult).toMatchFileSnapshot( + `../.././examples/transport-http|transport-http_method-get.output.test.txt`, + ) }) diff --git a/website/.vitepress/configExamples.ts b/website/.vitepress/configExamples.ts index a1de93144..6f8e8ef8c 100644 --- a/website/.vitepress/configExamples.ts +++ b/website/.vitepress/configExamples.ts @@ -5,37 +5,17 @@ export const sidebarExamples: DefaultTheme.SidebarItem[] = [ 'text': 'Transport Memory', 'link': '/examples/transport-memory', }, - { - 'text': 'DynamicHeaders', - 'link': '/examples/dynamicHeaders', - }, - { - 'text': 'DynamicHeaders.output Encoder', - 'link': '/examples/dynamicHeaders.output-encoder', - }, - { - 'text': 'Transport Http Fetch', - 'link': '/examples/transport-http_fetch', - }, - { - 'text': 'Transport Http Abort', - 'link': '/examples/transport-http_abort', - }, - { - 'text': 'Transport Http RequestInput', - 'link': '/examples/transport-http_RequestInput', - }, { 'text': 'Raw Typed', 'link': '/examples/raw-typed', }, { - 'text': 'RawString Typed', - 'link': '/examples/rawString-typed', + 'text': 'Raw String Typed', + 'link': '/examples/raw-string-typed', }, { - 'text': 'RawString', - 'link': '/examples/rawString', + 'text': 'Raw String', + 'link': '/examples/raw-string', }, { 'text': 'Raw', @@ -50,4 +30,29 @@ export const sidebarExamples: DefaultTheme.SidebarItem[] = [ }, ], }, + { + 'text': 'Transport Http', + 'items': [ + { + 'text': 'Request Input', + 'link': '/examples/transport-http-request-input', + }, + { + 'text': 'Abort', + 'link': '/examples/transport-http-abort', + }, + { + 'text': 'Fetch', + 'link': '/examples/transport-http-fetch', + }, + { + 'text': 'Dynamic Headers', + 'link': '/examples/transport-http-dynamic-headers', + }, + { + 'text': 'Method Get', + 'link': '/examples/transport-http-method-get', + }, + ], + }, ] diff --git a/website/.vitepress/theme/custom.css b/website/.vitepress/theme/custom.css index c3ae98ec1..0c1eaee6b 100644 --- a/website/.vitepress/theme/custom.css +++ b/website/.vitepress/theme/custom.css @@ -60,3 +60,13 @@ .VPContent h6 a:hover { opacity: 1; } + +/* Improve Code Blocks */ + +:root { + --vp-code-line-highlight-color: hsla(57.82, 100%, 63%, 0.37); +} + +.dark { + --vp-code-line-highlight-color: hsla(208.91, 100%, 84.25%, 0.05); +} diff --git a/website/content/examples/.md b/website/content/examples/.md new file mode 100644 index 000000000..4352b5623 --- /dev/null +++ b/website/content/examples/.md @@ -0,0 +1,39 @@ +--- +aside: false +--- + +# + +```ts twoslash +import { GraphQLObjectType, GraphQLSchema, GraphQLString } from 'graphql' +import { Graffle } from 'graffle' + +const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: `Query`, + fields: { + foo: { + type: GraphQLString, + resolve: () => `bar`, + }, + }, + }), +}) + +const graffle = Graffle.create({ schema }) + +const result = await graffle.rawString({ document: `{ foo }` }) + +console.log(result) +// ^? +``` + +#### Outputs + +```json +{ + "data": { + "foo": "bar" + } +} +``` \ No newline at end of file diff --git a/website/content/examples/dynamicHeaders.output-encoder.md b/website/content/examples/dynamicHeaders.output-encoder.md deleted file mode 100644 index 6603223c6..000000000 --- a/website/content/examples/dynamicHeaders.output-encoder.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -aside: false ---- - -# DynamicHeaders.output Encoder - -```ts twoslash -export const encode = (snapshot: string) => { - return snapshot.replace( - /x-sent-at-time: '\d+'/, - `x-sent-at-time: 'DYNAMIC_VALUE'`, - ) -} -``` - -#### Output - -```txt -``` diff --git a/website/content/examples/generated-arguments.md b/website/content/examples/generated-arguments.md index 4c5dde46b..13580e695 100644 --- a/website/content/examples/generated-arguments.md +++ b/website/content/examples/generated-arguments.md @@ -4,10 +4,9 @@ aside: false # Arguments + ```ts twoslash -import './graffle/Global.js' -// ---cut--- -import { Graffle as SocialStudies } from './graffle/__.js' +import { SocialStudies } from './$/generated-clients/SocialStudies/__.js' const socialStudies = SocialStudies.create() @@ -20,9 +19,11 @@ const countries = await socialStudies.query.countries({ console.log(countries) // ^? ``` + -#### Output +#### Outputs + ```json [ { @@ -45,3 +46,4 @@ console.log(countries) } ] ``` + diff --git a/website/content/examples/rawString-typed.md b/website/content/examples/raw-string-typed.md similarity index 89% rename from website/content/examples/rawString-typed.md rename to website/content/examples/raw-string-typed.md index 2dfe61201..c836b92e0 100644 --- a/website/content/examples/rawString-typed.md +++ b/website/content/examples/raw-string-typed.md @@ -2,8 +2,9 @@ aside: false --- -# RawString Typed +# Raw String Typed + ```ts twoslash import { Graffle } from 'graffle' // todo from 'graffle/utils' @@ -43,9 +44,11 @@ const result = await graffle.rawString({ console.log(result.data?.countries) // ^? ``` + -#### Output +#### Outputs + ```txt [ { name: 'Canada', continent: { name: 'North America' } }, @@ -53,3 +56,4 @@ console.log(result.data?.countries) { name: 'Japan', continent: { name: 'Asia' } } ] ``` + diff --git a/website/content/examples/rawString.md b/website/content/examples/raw-string.md similarity index 95% rename from website/content/examples/rawString.md rename to website/content/examples/raw-string.md index aa2286ee6..0aab4e052 100644 --- a/website/content/examples/rawString.md +++ b/website/content/examples/raw-string.md @@ -2,8 +2,9 @@ aside: false --- -# RawString +# Raw String + ```ts twoslash import { Graffle } from 'graffle' @@ -26,9 +27,11 @@ const result = await graffle.rawString({ console.log(result.data) // ^? ``` + -#### Output +#### Outputs + ```txt { countries: [ @@ -136,3 +139,4 @@ console.log(result.data) ] } ``` + diff --git a/website/content/examples/raw-typed.md b/website/content/examples/raw-typed.md index 596fcdedb..fd7376279 100644 --- a/website/content/examples/raw-typed.md +++ b/website/content/examples/raw-typed.md @@ -4,9 +4,10 @@ aside: false # Raw Typed + ```ts twoslash -import { gql, Graffle } from 'graffle' import type { TypedQueryDocumentNode } from 'graphql' +import { gql, Graffle } from 'graffle' const graffle = Graffle.create({ schema: `https://countries.trevorblades.com/graphql`, @@ -21,10 +22,7 @@ const graffle = Graffle.create({ */ { - const document = gql< - { countries: { name: string; continent: { name: string } }[] }, - { filter: string[] } - >` + const document = gql<{ countries: { name: string; continent: { name: string } }[] }, { filter: string[] }>` query countries ($filter: [String!]) { countries (filter: { name: { in: $filter } }) { name @@ -35,10 +33,7 @@ const graffle = Graffle.create({ } ` - const result = await graffle.raw({ - document, - variables: { filter: [`Canada`, `Germany`, `Japan`] }, - }) + const result = await graffle.raw({ document, variables: { filter: [`Canada`, `Germany`, `Japan`] } }) console.log(result.data?.countries) } @@ -68,26 +63,30 @@ const graffle = Graffle.create({ } ` - const result = await graffle.raw({ - document, - variables: { filter: [`Canada`, `Germany`, `Japan`] }, - }) + const result = await graffle.raw({ document, variables: { filter: [`Canada`, `Germany`, `Japan`] } }) console.log(result.data?.countries) } ``` + -#### Output +#### Outputs + ```txt [ { name: 'Canada', continent: { name: 'North America' } }, { name: 'Germany', continent: { name: 'Europe' } }, { name: 'Japan', continent: { name: 'Asia' } } ] +``` + + +```txt [ { name: 'Canada', continent: { name: 'North America' } }, { name: 'Germany', continent: { name: 'Europe' } }, { name: 'Japan', continent: { name: 'Asia' } } ] ``` + diff --git a/website/content/examples/raw.md b/website/content/examples/raw.md index b2e2b4d71..f0f5789e3 100644 --- a/website/content/examples/raw.md +++ b/website/content/examples/raw.md @@ -4,6 +4,7 @@ aside: false # Raw + ```ts twoslash import { gql, Graffle } from 'graffle' @@ -28,9 +29,11 @@ const result = await graffle.raw({ console.log(result.data) // ^? ``` + -#### Output +#### Outputs + ```txt { countries: [ @@ -40,3 +43,4 @@ console.log(result.data) ] } ``` + diff --git a/website/content/examples/transport-http_abort.md b/website/content/examples/transport-http-abort.md similarity index 50% rename from website/content/examples/transport-http_abort.md rename to website/content/examples/transport-http-abort.md index 7b8f8f190..c52d0d342 100644 --- a/website/content/examples/transport-http_abort.md +++ b/website/content/examples/transport-http-abort.md @@ -2,14 +2,13 @@ aside: false --- -# Transport Http Abort +# Abort -```ts twoslash -/** - * It is possible to cancel a request using an `AbortController` signal. - */ +It is possible to cancel a request using an `AbortController` signal. -import { gql, Graffle } from 'graffle' + +```ts twoslash +import { Graffle } from 'graffle' const abortController = new AbortController() @@ -18,9 +17,9 @@ const graffle = Graffle.create({ }) const resultPromise = graffle - .with({ request: { signal: abortController.signal } }) - .raw({ - document: gql` + .with({ transport: { signal: abortController.signal } }) + .rawString({ + document: ` { countries { name @@ -31,18 +30,19 @@ const resultPromise = graffle abortController.abort() -const result = await resultPromise.catch((error: unknown) => - (error as Error).message -) +const result = await resultPromise.catch((error: unknown) => (error as Error).message) console.log(result) // ^? // todo .with(...) variant ``` + -#### Output +#### Outputs + ```txt 'This operation was aborted' ``` + diff --git a/website/content/examples/dynamicHeaders.md b/website/content/examples/transport-http-dynamic-headers.md similarity index 69% rename from website/content/examples/dynamicHeaders.md rename to website/content/examples/transport-http-dynamic-headers.md index d2d1546de..fa2b8edb8 100644 --- a/website/content/examples/dynamicHeaders.md +++ b/website/content/examples/transport-http-dynamic-headers.md @@ -2,8 +2,9 @@ aside: false --- -# DynamicHeaders +# Dynamic Headers + ```ts twoslash import { Graffle } from 'graffle' @@ -22,24 +23,30 @@ const graffle = Graffle }) }) .use(async ({ exchange }) => { + // todo wrong type / runtime value console.log(exchange.input.request) return exchange() }) await graffle.rawString({ document: `{ languages { code } }` }) ``` + -#### Output +#### Outputs + ```txt { - url: 'https://countries.trevorblades.com/graphql', - body: '{"query":"{ languages { code } }"}', - method: 'POST', + methodMode: 'post', headers: Headers { accept: 'application/graphql-response+json; charset=utf-8, application/json; charset=utf-8', 'content-type': 'application/json', - 'x-sent-at-time': '1725556925194' - } + 'x-sent-at-time': '1725648269194' + }, + signal: undefined, + method: 'post', + url: 'https://countries.trevorblades.com/graphql', + body: '{"query":"{ languages { code } }"}' } ``` + diff --git a/website/content/examples/transport-http_fetch.md b/website/content/examples/transport-http-fetch.md similarity index 60% rename from website/content/examples/transport-http_fetch.md rename to website/content/examples/transport-http-fetch.md index b86f55342..07295ba55 100644 --- a/website/content/examples/transport-http_fetch.md +++ b/website/content/examples/transport-http-fetch.md @@ -2,8 +2,9 @@ aside: false --- -# Transport Http Fetch +# Fetch + ```ts twoslash import { Graffle } from 'graffle' @@ -15,27 +16,23 @@ const graffle = Graffle return await exchange({ using: { fetch: async () => { - return new Response( - JSON.stringify({ - data: { countries: [{ name: `Canada Mocked!` }] }, - }), - ) + return new Response(JSON.stringify({ data: { countries: [{ name: `Canada Mocked!` }] } })) }, }, }) }, }) -const countries = await graffle.rawString({ - document: `{ countries { name } }`, -}) +const countries = await graffle.rawString({ document: `{ countries { name } }` }) console.log(countries.data) // ^? ``` + -#### Output +#### Outputs + ```json { "countries": [ @@ -45,3 +42,4 @@ console.log(countries.data) ] } ``` + diff --git a/website/content/examples/transport-http-method-get.md b/website/content/examples/transport-http-method-get.md new file mode 100644 index 000000000..4c6dc126b --- /dev/null +++ b/website/content/examples/transport-http-method-get.md @@ -0,0 +1,94 @@ +--- +aside: false +--- + +# Method Get + +This example shows usage of the `getReads` method mode for the HTTP transport. This mode causes read-kind operations (query, subscription) +to be sent over HTTP GET method. Note write-kind operations (mutation) are still sent over HTTP POST method. + + +```ts twoslash +import { Pokemon } from './$/generated-clients/Pokemon/__.js' +import { schema } from './$/schemas/pokemon/schema.js' + +const server = await serveSchema({ schema }) + +const graffle = Pokemon + .create({ + schema: server.url, + transport: { methodMode: `getReads` }, // [!code highlight] + }) + .use(async ({ exchange }) => { + console.log(exchange.input.request) + return exchange() + }) + +// The following request will use an HTTP POST method because it is +// using a "mutation" type of operation. +await graffle.rawString({ document: `mutation addPokemon(attack:0, defense:0, hp:1, name:"Nano") { name }` }) + +// The following request will use an HTTP GET method because it +// is using a "query" type of operation. +await graffle.rawString({ document: `query { pokemonByName(name:"Nano") { hp } }` }) + +await server.stop() +``` + + +#### Outputs + + +```txt +{ + methodMode: 'getReads', + headers: Headers { + accept: 'application/graphql-response+json; charset=utf-8, application/json; charset=utf-8', + 'content-type': 'application/json' + }, + signal: undefined, + method: 'post', + url: URL { + href: 'http://localhost:3000/graphql', + origin: 'http://localhost:3000', + protocol: 'http:', + username: '', + password: '', + host: 'localhost:3000', + hostname: 'localhost', + port: '3000', + pathname: '/graphql', + search: '', + searchParams: URLSearchParams {}, + hash: '' + }, + body: '{"query":"mutation addPokemon(attack:0, defense:0, hp:1, name:\\"Nano\\") { name }"}' +} +``` + + +```txt +{ + methodMode: 'getReads', + headers: Headers { + accept: 'application/graphql-response+json; charset=utf-8, application/json; charset=utf-8' + }, + signal: undefined, + method: 'get', + url: URL { + href: 'http://localhost:3000/graphql?query=query+%7B+pokemonByName%28name%3A%22Nano%22%29+%7B+hp+%7D+%7D', + origin: 'http://localhost:3000', + protocol: 'http:', + username: '', + password: '', + host: 'localhost:3000', + hostname: 'localhost', + port: '3000', + pathname: '/graphql', + search: '?query=query+%7B+pokemonByName%28name%3A%22Nano%22%29+%7B+hp+%7D+%7D', + searchParams: URLSearchParams { 'query' => 'query { pokemonByName(name:"Nano") { hp } }' }, + hash: '' + } +} +``` + diff --git a/website/content/examples/transport-http_RequestInput.md b/website/content/examples/transport-http-request-input.md similarity index 67% rename from website/content/examples/transport-http_RequestInput.md rename to website/content/examples/transport-http-request-input.md index daa7e6ba1..fdc000dab 100644 --- a/website/content/examples/transport-http_RequestInput.md +++ b/website/content/examples/transport-http-request-input.md @@ -2,19 +2,22 @@ aside: false --- -# Transport Http RequestInput +# Request Input + ```ts twoslash import { Graffle } from 'graffle' const graffle = Graffle .create({ schema: `https://countries.trevorblades.com/graphql`, - request: { + transport: { headers: { authorization: `Bearer MY_TOKEN`, }, - mode: `cors`, + raw: { + mode: `cors`, + }, }, }) .use(async ({ exchange }) => { @@ -24,19 +27,24 @@ const graffle = Graffle await graffle.rawString({ document: `{ languages { code } }` }) ``` + -#### Output +#### Outputs + ```txt { - url: 'https://countries.trevorblades.com/graphql', - body: '{"query":"{ languages { code } }"}', - method: 'POST', + methodMode: 'post', headers: Headers { accept: 'application/graphql-response+json; charset=utf-8, application/json; charset=utf-8', 'content-type': 'application/json', authorization: 'Bearer MY_TOKEN' }, - mode: 'cors' + signal: undefined, + mode: 'cors', + method: 'post', + url: 'https://countries.trevorblades.com/graphql', + body: '{"query":"{ languages { code } }"}' } ``` + diff --git a/website/content/examples/transport-memory.md b/website/content/examples/transport-memory.md index 3b5470f88..b0974f35f 100644 --- a/website/content/examples/transport-memory.md +++ b/website/content/examples/transport-memory.md @@ -4,9 +4,10 @@ aside: false # Transport Memory + ```ts twoslash -import { Graffle } from 'graffle' import { GraphQLObjectType, GraphQLSchema, GraphQLString } from 'graphql' +import { Graffle } from 'graffle' const schema = new GraphQLSchema({ query: new GraphQLObjectType({ @@ -27,9 +28,11 @@ const result = await graffle.rawString({ document: `{ foo }` }) console.log(result) // ^? ``` + -#### Output +#### Outputs + ```json { "data": { @@ -37,3 +40,4 @@ console.log(result) } } ``` + diff --git a/website/content/guides/_example_links/RequestInput.md b/website/content/guides/_example_links/RequestInput.md index a9c466e59..975af63ca 100644 --- a/website/content/guides/_example_links/RequestInput.md +++ b/website/content/guides/_example_links/RequestInput.md @@ -1 +1 @@ -###### Examples -> [Transport Http RequestInput](../../examples/transport-http_RequestInput.md) +###### Examples -> [Request Input](../../examples/transport-http-request-input.md) diff --git a/website/content/guides/_example_links/abort.md b/website/content/guides/_example_links/abort.md index f5ac20410..cf791fe22 100644 --- a/website/content/guides/_example_links/abort.md +++ b/website/content/guides/_example_links/abort.md @@ -1 +1 @@ -###### Examples -> [Transport Http Abort](../../examples/transport-http_abort.md) +###### Examples -> [Abort](../../examples/transport-http-abort.md) diff --git a/website/content/guides/_example_links/fetch.md b/website/content/guides/_example_links/fetch.md index 4a609af99..ca6a24605 100644 --- a/website/content/guides/_example_links/fetch.md +++ b/website/content/guides/_example_links/fetch.md @@ -1 +1 @@ -###### Examples -> [Transport Http Fetch](../../examples/transport-http_fetch.md) +###### Examples -> [Fetch](../../examples/transport-http-fetch.md) diff --git a/website/content/guides/_example_links/generated|generated.md b/website/content/guides/_example_links/generated.md similarity index 100% rename from website/content/guides/_example_links/generated|generated.md rename to website/content/guides/_example_links/generated.md diff --git a/website/content/guides/_example_links/generated|generated_arguments.md b/website/content/guides/_example_links/generated_arguments.md similarity index 100% rename from website/content/guides/_example_links/generated|generated_arguments.md rename to website/content/guides/_example_links/generated_arguments.md diff --git a/website/content/guides/_example_links/headers.md b/website/content/guides/_example_links/headers.md index a86d14503..87fcb99d2 100644 --- a/website/content/guides/_example_links/headers.md +++ b/website/content/guides/_example_links/headers.md @@ -1 +1 @@ -###### Examples -> [DynamicHeaders.output Encoder](../../examples/dynamicHeaders.output-encoder.md) / [DynamicHeaders](../../examples/dynamicHeaders.md) +###### Examples -> [Dynamic Headers](../../examples/transport-http-dynamic-headers.md) diff --git a/website/content/guides/_example_links/method-get.md b/website/content/guides/_example_links/method-get.md new file mode 100644 index 000000000..3ab83cf61 --- /dev/null +++ b/website/content/guides/_example_links/method-get.md @@ -0,0 +1 @@ +###### Examples -> [Method Get](../../examples/transport-http-method-get.md) diff --git a/website/content/guides/_example_links/raw.md b/website/content/guides/_example_links/raw.md index 673e2d0e5..769685105 100644 --- a/website/content/guides/_example_links/raw.md +++ b/website/content/guides/_example_links/raw.md @@ -1 +1 @@ -###### Examples -> [Raw](../../examples/raw.md) / [RawString](../../examples/rawString.md) / [RawString Typed](../../examples/rawString-typed.md) / [Raw Typed](../../examples/raw-typed.md) +###### Examples -> [Raw](../../examples/raw.md) / [Raw String](../../examples/raw-string.md) / [Raw String Typed](../../examples/raw-string-typed.md) / [Raw Typed](../../examples/raw-typed.md) diff --git a/website/content/guides/_example_links/rawString.md b/website/content/guides/_example_links/rawString.md index 7b90205da..cca15510f 100644 --- a/website/content/guides/_example_links/rawString.md +++ b/website/content/guides/_example_links/rawString.md @@ -1 +1 @@ -###### Examples -> [RawString](../../examples/rawString.md) / [RawString Typed](../../examples/rawString-typed.md) +###### Examples -> [Raw String](../../examples/raw-string.md) / [Raw String Typed](../../examples/raw-string-typed.md) diff --git a/website/content/guides/_example_links/rawString_rawTyped.md b/website/content/guides/_example_links/rawString_rawTyped.md index b8f3e976e..3db5afc13 100644 --- a/website/content/guides/_example_links/rawString_rawTyped.md +++ b/website/content/guides/_example_links/rawString_rawTyped.md @@ -1 +1 @@ -###### Examples -> [RawString Typed](../../examples/rawString-typed.md) +###### Examples -> [Raw String Typed](../../examples/raw-string-typed.md) diff --git a/website/content/guides/_example_links/rawTyped.md b/website/content/guides/_example_links/rawTyped.md index e100b3b2e..ed1c436e2 100644 --- a/website/content/guides/_example_links/rawTyped.md +++ b/website/content/guides/_example_links/rawTyped.md @@ -1 +1 @@ -###### Examples -> [RawString Typed](../../examples/rawString-typed.md) / [Raw Typed](../../examples/raw-typed.md) +###### Examples -> [Raw String Typed](../../examples/raw-string-typed.md) / [Raw Typed](../../examples/raw-typed.md) diff --git a/website/content/guides/_example_links/raw_rawString.md b/website/content/guides/_example_links/raw_rawString.md index 7b90205da..cca15510f 100644 --- a/website/content/guides/_example_links/raw_rawString.md +++ b/website/content/guides/_example_links/raw_rawString.md @@ -1 +1 @@ -###### Examples -> [RawString](../../examples/rawString.md) / [RawString Typed](../../examples/rawString-typed.md) +###### Examples -> [Raw String](../../examples/raw-string.md) / [Raw String Typed](../../examples/raw-string-typed.md) diff --git a/website/content/guides/_example_links/raw_rawString_rawTyped.md b/website/content/guides/_example_links/raw_rawString_rawTyped.md index b8f3e976e..3db5afc13 100644 --- a/website/content/guides/_example_links/raw_rawString_rawTyped.md +++ b/website/content/guides/_example_links/raw_rawString_rawTyped.md @@ -1 +1 @@ -###### Examples -> [RawString Typed](../../examples/rawString-typed.md) +###### Examples -> [Raw String Typed](../../examples/raw-string-typed.md) diff --git a/website/content/guides/_example_links/raw_rawTyped.md b/website/content/guides/_example_links/raw_rawTyped.md index e100b3b2e..ed1c436e2 100644 --- a/website/content/guides/_example_links/raw_rawTyped.md +++ b/website/content/guides/_example_links/raw_rawTyped.md @@ -1 +1 @@ -###### Examples -> [RawString Typed](../../examples/rawString-typed.md) / [Raw Typed](../../examples/raw-typed.md) +###### Examples -> [Raw String Typed](../../examples/raw-string-typed.md) / [Raw Typed](../../examples/raw-typed.md) diff --git a/website/content/guides/_example_links/transport-http.md b/website/content/guides/_example_links/transport-http.md index 8b7c40899..689e0b261 100644 --- a/website/content/guides/_example_links/transport-http.md +++ b/website/content/guides/_example_links/transport-http.md @@ -1 +1 @@ -###### Examples -> [Transport Http RequestInput](../../examples/transport-http_RequestInput.md) / [Transport Http Abort](../../examples/transport-http_abort.md) / [Transport Http Fetch](../../examples/transport-http_fetch.md) / [DynamicHeaders.output Encoder](../../examples/dynamicHeaders.output-encoder.md) / [DynamicHeaders](../../examples/dynamicHeaders.md) +###### Examples -> [Request Input](../../examples/transport-http-request-input.md) / [Abort](../../examples/transport-http-abort.md) / [Fetch](../../examples/transport-http-fetch.md) / [Dynamic Headers](../../examples/transport-http-dynamic-headers.md) / [Method Get](../../examples/transport-http-method-get.md) diff --git a/website/content/guides/_example_links/transport-http_RequestInput.md b/website/content/guides/_example_links/transport-http_RequestInput.md index a9c466e59..975af63ca 100644 --- a/website/content/guides/_example_links/transport-http_RequestInput.md +++ b/website/content/guides/_example_links/transport-http_RequestInput.md @@ -1 +1 @@ -###### Examples -> [Transport Http RequestInput](../../examples/transport-http_RequestInput.md) +###### Examples -> [Request Input](../../examples/transport-http-request-input.md) diff --git a/website/content/guides/_example_links/transport-http_abort.md b/website/content/guides/_example_links/transport-http_abort.md index f5ac20410..cf791fe22 100644 --- a/website/content/guides/_example_links/transport-http_abort.md +++ b/website/content/guides/_example_links/transport-http_abort.md @@ -1 +1 @@ -###### Examples -> [Transport Http Abort](../../examples/transport-http_abort.md) +###### Examples -> [Abort](../../examples/transport-http-abort.md) diff --git a/website/content/guides/_example_links/transport-http_fetch.md b/website/content/guides/_example_links/transport-http_fetch.md index 4a609af99..ca6a24605 100644 --- a/website/content/guides/_example_links/transport-http_fetch.md +++ b/website/content/guides/_example_links/transport-http_fetch.md @@ -1 +1 @@ -###### Examples -> [Transport Http Fetch](../../examples/transport-http_fetch.md) +###### Examples -> [Fetch](../../examples/transport-http-fetch.md) diff --git a/website/content/guides/_example_links/transport-http_headers.md b/website/content/guides/_example_links/transport-http_headers.md index a86d14503..87fcb99d2 100644 --- a/website/content/guides/_example_links/transport-http_headers.md +++ b/website/content/guides/_example_links/transport-http_headers.md @@ -1 +1 @@ -###### Examples -> [DynamicHeaders.output Encoder](../../examples/dynamicHeaders.output-encoder.md) / [DynamicHeaders](../../examples/dynamicHeaders.md) +###### Examples -> [Dynamic Headers](../../examples/transport-http-dynamic-headers.md) diff --git a/website/content/guides/_example_links/transport-http_method-get.md b/website/content/guides/_example_links/transport-http_method-get.md new file mode 100644 index 000000000..3ab83cf61 --- /dev/null +++ b/website/content/guides/_example_links/transport-http_method-get.md @@ -0,0 +1 @@ +###### Examples -> [Method Get](../../examples/transport-http-method-get.md) diff --git a/website/content/guides/transports/http.md b/website/content/guides/transports/http.md index 29b6099c2..6d95baf2e 100644 --- a/website/content/guides/transports/http.md +++ b/website/content/guides/transports/http.md @@ -26,16 +26,43 @@ Graffle.create({ ## Configuration - +You can generally configure aspects of the transport in three ways: -When using this transport, you can configure `request` for most aspects of the `fetch` `RequestInit`: +1. In the constructor under `transport`. +2. Using `with` under `transport`. +3. Using extensions. + +```ts twoslash +import * as GGGGGGGG from 'graffle' +// @noErrors +GGGGGGGG. +// ^| +// const graffle = Graffle.create({ schema: 'ignoreme' }) +// // ---cut--- +// graffle.create({ +// transport: { +// headers: { authorization: '...' }, +// raw: { mode: 'cors' }, +// }, +// }) +``` + +## GET + + + +By default all requests use HTTP POST. However you can configure queries and subscriptions to be sent over HTTP GET. ```ts graffle.create({ - request: { headers: { authorization: '...' }, mode: 'cors' }, + transport: { methodMode: 'getReads' }, }) ``` +## POST + +## Raw + ## Anyware Hooks are augmented in the following ways: diff --git a/website/content/index.md b/website/content/index.md index 8e5908ff0..e898caba8 100644 --- a/website/content/index.md +++ b/website/content/index.md @@ -13,16 +13,16 @@ hero: text: Examples link: /examples/raw features: - - title: Opt-in Generation - details: Begin with a traditional static library and seamlessly transition to a more powerful generated one when you want. + - title: Spec Compliant + details: Graffle complies with the GraphQL over HTTP and GraphQL Multipart Request specifications. - title: Extensible # TODO Ability for extensions to add methods. details: Powerful type-safe extension system. Intercept and manipulate inputs, outputs, and core with hooks; Add new methods; And more. - title: In-Memory Schemas Too details: Not just a great way to query GraphQL APIs. Execute documents against in memory schemas just as easily with nearly the same interface. - - title: Spec Compliant - details: Graffle complies with the GraphQL over HTTP specification. # TODO support for subscription type. + - title: Opt-in Generation + details: Begin with a traditional static library and seamlessly transition to a more powerful generated one when you want. - title: Type Safe Results
( gen ) details: All result types are automatically inferred based on your document structure across all GraphQL features including selection sets, directives, fragments, interfaces, and unions. - title: Schema Tailored Methods
( gen )