diff --git a/docs/pages/docs/guides/testing.mdx b/docs/pages/docs/guides/testing.mdx index 3610ceb..c883b87 100644 --- a/docs/pages/docs/guides/testing.mdx +++ b/docs/pages/docs/guides/testing.mdx @@ -529,3 +529,33 @@ const MOCKED_ENV = { ``` + +### Optional: Mocking Dependencies + +In some cases, you might want to mock certain files or modules during testing. This cannot be done with the `.pylon/index.js` file because it is a bundled output. Instead, you can directly import and use the `./src/index.ts` file. This approach allows you to mock dependencies and have more control over your tests. + +However, when using `./src/index.ts` directly, you need to manually set up the `handler` to handle GraphQL requests. + +```typescript +import {handler} from '@getcronit/pylon' + +import app, {graphql} from './src/index' + +app.use(handler({graphql})) + +... your tests here ... +``` + +This setup allows you to directly import and use the `./src/index.ts` file in your tests, giving you more flexibility and control over your test environment. + +If you use the `config` export in your `./src/index.ts` file, you can also pass it to the `handler` function: + +```typescript +import {handler} from '@getcronit/pylon' + +import app, {graphql, config} from './src/index' + +app.use(handler({graphql, config})) + +... your tests here ... +``` diff --git a/examples/bun-testing/pylon.test.ts b/examples/bun-testing/pylon.test.ts index a06f283..8f582d7 100644 --- a/examples/bun-testing/pylon.test.ts +++ b/examples/bun-testing/pylon.test.ts @@ -1,7 +1,11 @@ import {describe, expect, test} from 'bun:test' // Make sure to run `bun run build` before running this test -import app from './.pylon/index' +import {handler} from '@getcronit/pylon' + +import app, {graphql} from './src/index' + +app.use(handler({graphql})) describe('GraphQL API', () => { test('Query.hello', async () => { diff --git a/packages/pylon-builder/src/bundler/bundler.ts b/packages/pylon-builder/src/bundler/bundler.ts index d369d2f..b68aec7 100644 --- a/packages/pylon-builder/src/bundler/bundler.ts +++ b/packages/pylon-builder/src/bundler/bundler.ts @@ -41,7 +41,9 @@ export class Bundler { const buildOnce = async () => { const startTime = Date.now() - const {typeDefs, resolvers: baseResolvers} = options.getBuildDefs() + const {typeDefs, resolvers} = options.getBuildDefs() + + const preparedResolvers = prepareObjectInjection(resolvers) const injectCodePlugin: Plugin = { name: 'inject-code', @@ -56,34 +58,22 @@ export class Bundler { contents: contents + ` - import {graphqlHandler} from "@getcronit/pylon" - app.use('/graphql', async c => { - const typeDefs = ${JSON.stringify(typeDefs)} - const resolvers = { - ...graphql, - ...${prepareObjectInjection(baseResolvers)} - } + import {handler as __internalPylonHandler} from "@getcronit/pylon" - let pylonConfig = undefined + let __internalPylonConfig = undefined - try { - pylonConfig = config - } catch { - // config is not declared, pylonConfig remains undefined - } - - let exCtx = undefined - - try { - exCtx = c.executionCtx - } catch (e) {} - - return graphqlHandler(c)({ - typeDefs, - resolvers, - config: pylonConfig - }).fetch(c.req.raw, c.env, exCtx || {}) - }) + try { + __internalPylonConfig = config + } catch { + // config is not declared, pylonConfig remains undefined + } + + app.use(__internalPylonHandler({ + typeDefs: ${JSON.stringify(typeDefs)}, + graphql, + resolvers: ${preparedResolvers}, + config: __internalPylonConfig + })) ` } } @@ -137,6 +127,28 @@ export class Bundler { 0 ) + // Write the typeDefs to a file + const typeDefsPath = path.join( + process.cwd(), + this.outputDir, + 'schema.graphql' + ) + + await fs.promises.writeFile(typeDefsPath, typeDefs) + + // Write base resolvers to a file + + const resolversPath = path.join( + process.cwd(), + this.outputDir, + 'resolvers.js' + ) + + await fs.promises.writeFile( + resolversPath, + `export const resolvers = ${preparedResolvers}` + ) + return { totalFiles, totalSize, diff --git a/packages/pylon/src/app/handler/graphql-handler.ts b/packages/pylon/src/app/handler/graphql-handler.ts deleted file mode 100644 index 469c022..0000000 --- a/packages/pylon/src/app/handler/graphql-handler.ts +++ /dev/null @@ -1,116 +0,0 @@ -import {createSchema, createYoga} from 'graphql-yoga' -import {GraphQLScalarType, Kind} from 'graphql' - -import {useSentry} from '../envelop/use-sentry' -import {Context} from '../../context' -import {resolversToGraphQLResolvers} from '../../define-pylon' -import {PylonConfig} from '../..' - -export interface SchemaOptions { - typeDefs: string - resolvers: { - Query: Record - Mutation: Record - Subscription: Record - } - config?: PylonConfig -} - -export const graphqlHandler = - (c: Context) => - ({typeDefs, resolvers, config}: SchemaOptions) => { - resolvers = resolversToGraphQLResolvers(resolvers) - - const schema = createSchema({ - typeDefs, - resolvers: { - ...resolvers, - // Transforms a date object to a timestamp - Date: new GraphQLScalarType({ - name: 'Date', - description: 'Date custom scalar type', - parseValue(value) { - if (typeof value === 'string') { - return new Date(value) - } - - if (value instanceof Date) { - return value // value from the client - } - - throw Error( - 'GraphQL Date Scalar parseValue expected a `Date` or string' - ) - }, - serialize(value) { - if (value instanceof Date) { - return value.toISOString() // value sent to the client - } - - throw Error( - 'GraphQL Date Scalar serializer expected a `Date` object' - ) - }, - parseLiteral(ast) { - if (ast.kind === Kind.INT) { - return new Date(parseInt(ast.value, 10)) - } else if (ast.kind === Kind.STRING) { - return new Date(ast.value) - } - - return null - } - }), - Number: new GraphQLScalarType({ - name: 'Number', - description: 'Custom scalar that handles both integers and floats', - - // Parsing input from query variables - parseValue(value) { - if (typeof value !== 'number') { - throw new TypeError(`Value is not a number: ${value}`) - } - return value // Valid number - }, - - // Validation when sending from client (input literals) - parseLiteral(ast) { - if (ast.kind === Kind.INT || ast.kind === Kind.FLOAT) { - return parseFloat(ast.value) // Convert the value to a float - } - throw new TypeError( - `Value is not a valid number or float: ${ - 'value' in ast ? ast.value : ast - }` - ) - }, - - // Serialize output to be sent to the client - serialize(value) { - if (typeof value !== 'number') { - throw new TypeError(`Value is not a number: ${value}`) - } - return value - } - }) - } - }) - - const yoga = createYoga({ - landingPage: false, - graphiql: req => { - return { - shouldPersistHeaders: true, - title: 'Pylon Playground', - defaultQuery: `# Welcome to the Pylon Playground!` - } - }, - - ...config, - plugins: [useSentry(), ...(config?.plugins || [])], - schema, - context: c - }) - - return yoga - } diff --git a/packages/pylon/src/app/handler/pylon-handler.ts b/packages/pylon/src/app/handler/pylon-handler.ts new file mode 100644 index 0000000..38c8d9e --- /dev/null +++ b/packages/pylon/src/app/handler/pylon-handler.ts @@ -0,0 +1,154 @@ +import {createSchema, createYoga} from 'graphql-yoga' +import {GraphQLScalarType, Kind} from 'graphql' + +import {useSentry} from '../envelop/use-sentry' +import {Context} from '../../context' +import {resolversToGraphQLResolvers} from '../../define-pylon' +import {PylonConfig} from '../..' +import {readFileSync} from 'fs' +import path from 'path' + +interface PylonHandlerOptions { + graphql: { + Query: Record + Mutation?: Record + Subscription?: Record + } + config?: PylonConfig +} + +export const handler = (options: PylonHandlerOptions) => { + let {typeDefs, resolvers, graphql, config} = + options as PylonHandlerOptions & { + typeDefs?: string + resolvers?: Record + } + + if (!typeDefs) { + // Try to read the schema from the default location + const schemaPath = path.join(process.cwd(), '.pylon', 'schema.graphql') + + // If `schemaPath` is provided, read the schema from the file + if (schemaPath) { + typeDefs = readFileSync(schemaPath, 'utf-8') + } + } + + if (!typeDefs) { + throw new Error('No schema provided.') + } + + if (!resolvers) { + // Try to read the resolvers from the default location + const resolversPath = path.join(process.cwd(), '.pylon', 'resolvers.js') + + // If `resolversPath` is provided, read the resolvers from the file + + if (resolversPath) { + resolvers = require(resolversPath).resolvers + } + } + + const graphqlResolvers = resolversToGraphQLResolvers(graphql) + + const schema = createSchema({ + typeDefs, + resolvers: { + ...graphqlResolvers, + ...resolvers, + // Transforms a date object to a timestamp + Date: new GraphQLScalarType({ + name: 'Date', + description: 'Date custom scalar type', + parseValue(value) { + if (typeof value === 'string') { + return new Date(value) + } + + if (value instanceof Date) { + return value // value from the client + } + + throw Error( + 'GraphQL Date Scalar parseValue expected a `Date` or string' + ) + }, + serialize(value) { + if (value instanceof Date) { + return value.toISOString() // value sent to the client + } + + throw Error('GraphQL Date Scalar serializer expected a `Date` object') + }, + parseLiteral(ast) { + if (ast.kind === Kind.INT) { + return new Date(parseInt(ast.value, 10)) + } else if (ast.kind === Kind.STRING) { + return new Date(ast.value) + } + + return null + } + }), + Number: new GraphQLScalarType({ + name: 'Number', + description: 'Custom scalar that handles both integers and floats', + + // Parsing input from query variables + parseValue(value) { + if (typeof value !== 'number') { + throw new TypeError(`Value is not a number: ${value}`) + } + return value // Valid number + }, + + // Validation when sending from client (input literals) + parseLiteral(ast) { + if (ast.kind === Kind.INT || ast.kind === Kind.FLOAT) { + return parseFloat(ast.value) // Convert the value to a float + } + throw new TypeError( + `Value is not a valid number or float: ${ + 'value' in ast ? ast.value : ast + }` + ) + }, + + // Serialize output to be sent to the client + serialize(value) { + if (typeof value !== 'number') { + throw new TypeError(`Value is not a number: ${value}`) + } + return value + } + }) + } + }) + + const yoga = createYoga({ + landingPage: false, + graphiql: req => { + return { + shouldPersistHeaders: true, + title: 'Pylon Playground', + defaultQuery: `# Welcome to the Pylon Playground!` + } + }, + graphqlEndpoint: '/graphql', + ...config, + plugins: [useSentry(), ...(config?.plugins || [])], + schema + }) + + const handler = async (c: Context): Promise => { + let executionContext: Context['executionCtx'] | {} = {} + + try { + executionContext = c.executionCtx + } catch (e) {} + + return yoga.fetch(c.req.raw, c.env, executionContext) + } + + return handler +} diff --git a/packages/pylon/src/define-pylon.ts b/packages/pylon/src/define-pylon.ts index 9ca0907..3945ba0 100644 --- a/packages/pylon/src/define-pylon.ts +++ b/packages/pylon/src/define-pylon.ts @@ -14,8 +14,8 @@ import {isAsyncIterable, Maybe} from 'graphql-yoga' export interface Resolvers { Query: Record - Mutation: Record - Subscription: Record + Mutation?: Record + Subscription?: Record } type FunctionWrapper = (fn: (...args: any[]) => any) => (...args: any[]) => any diff --git a/packages/pylon/src/index.ts b/packages/pylon/src/index.ts index 5f90a9d..3a1980d 100644 --- a/packages/pylon/src/index.ts +++ b/packages/pylon/src/index.ts @@ -13,7 +13,7 @@ export { setContext } from './context.js' export {app} from './app/index.js' -export {graphqlHandler} from './app/handler/graphql-handler.js' +export {handler} from './app/handler/pylon-handler.js' export {getEnv} from './get-env.js' export {createDecorator} from './create-decorator.js' export {createPubSub as experimentalCreatePubSub} from 'graphql-yoga'