import { Context, Data, Effect, Layer, Predicate, Ref, Schema as EffectSchema, Scope, } from "effect"; import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; import { GraphQLNonNull, GraphQLObjectType, GraphQLSchema, GraphQLString } from "graphql"; import { createYoga, type GraphQLParams, type YogaInitialContext } from "graphql-yoga"; import { serveTestHttpApp } from "@executor-js/sdk/testing"; const GraphqlRequestPayload = EffectSchema.Struct({ query: EffectSchema.optional(EffectSchema.String), variables: EffectSchema.optional(EffectSchema.Record(EffectSchema.String, EffectSchema.Unknown)), operationName: EffectSchema.optional(EffectSchema.NullOr(EffectSchema.String)), }); type GraphqlRequestPayload = typeof GraphqlRequestPayload.Type; export interface GraphqlTestRequest { readonly url: string; readonly method: string; readonly path: string; readonly headers: Readonly>; readonly payload: GraphqlRequestPayload; } export interface GraphqlTestContext { readonly request: GraphqlTestRequest; } export interface GraphqlTestServerOptions { readonly schema: GraphQLSchema; readonly path?: string; } export interface GraphqlTestServerShape { readonly endpoint: string; readonly schema: GraphQLSchema; readonly requests: Effect.Effect; readonly clearRequests: Effect.Effect; } class GraphqlTestServerAddressError extends Data.TaggedError("GraphqlTestServerAddressError")<{ readonly address: unknown; }> {} class GraphqlTestServerHandlerError extends Data.TaggedError("GraphqlTestServerHandlerError")<{ readonly cause: unknown; }> {} const headersFromRequest = (headers: Headers): Readonly> => Object.fromEntries(headers.entries()); const payloadFromParams = (params: GraphQLParams): GraphqlRequestPayload => ({ query: params.query, variables: typeof params.variables === "object" && params.variables !== null ? params.variables : undefined, operationName: params.operationName ?? null, }); const captureRequest = ( initial: YogaInitialContext, requests: Ref.Ref, ) => { const url = new URL(initial.request.url); const captured: GraphqlTestRequest = { url: initial.request.url, method: initial.request.method, path: url.pathname, headers: headersFromRequest(initial.request.headers), payload: payloadFromParams(initial.params), }; return Effect.runPromise( Ref.update(requests, (all) => [...all, captured]).pipe(Effect.as(captured)), ); }; export const serveGraphqlTestServer = ( options: GraphqlTestServerOptions, ): Effect.Effect< GraphqlTestServerShape, GraphqlTestServerAddressError | GraphqlTestServerHandlerError, Scope.Scope > => Effect.gen(function* () { const requests = yield* Ref.make([]); const path = options.path ?? "/graphql"; const yoga = createYoga, GraphqlTestContext>({ schema: options.schema, graphqlEndpoint: path, graphiql: false, landingPage: false, logging: false, maskedErrors: false, context: (initial) => captureRequest(initial, requests).then((request) => ({ request, })), }); const server = yield* serveTestHttpApp((request) => Effect.gen(function* () { const webRequest = yield* HttpServerRequest.toWeb(request); const response = yield* Effect.promise(() => Promise.resolve(yoga.handle(webRequest, {}))); return HttpServerResponse.fromWeb(response); }).pipe( Effect.catch(() => Effect.succeed( HttpServerResponse.text("GraphQL test server failed", { status: 500, contentType: "text/plain", }), ), ), ), ).pipe( Effect.mapError((error) => Predicate.isTagged(error, "TestHttpServerAddressError") ? new GraphqlTestServerAddressError({ address: error.address }) : new GraphqlTestServerHandlerError({ cause: error.cause }), ), ); return { endpoint: server.url(path), schema: options.schema, requests: Ref.get(requests), clearRequests: Ref.set(requests, []), }; }); export class GraphqlTestServer extends Context.Service()( "@executor-js/plugin-graphql/testing/GraphqlTestServer", ) { static readonly layer = (options: GraphqlTestServerOptions) => Layer.effect(GraphqlTestServer, serveGraphqlTestServer(options)); } const stringArgument = ( args: Readonly>, key: string, fallback: string, ): string => { const value = args[key]; return typeof value === "string" ? value : fallback; }; export const makeGreetingGraphqlSchema = (): GraphQLSchema => { const Query = new GraphQLObjectType({ name: "Query", fields: { hello: { type: GraphQLString, description: "Say hello", args: { name: { type: GraphQLString }, }, resolve: (_source, args) => `Hello ${stringArgument(args, "name", "world")}`, }, }, }); const Mutation = new GraphQLObjectType({ name: "Mutation", fields: { setGreeting: { type: GraphQLString, description: "Set greeting message", args: { message: { type: new GraphQLNonNull(GraphQLString) }, }, resolve: (_source, args) => stringArgument(args, "message", ""), }, }, }); return new GraphQLSchema({ query: Query, mutation: Mutation }); }; export const TestLayers = { greeting: () => GraphqlTestServer.layer({ schema: makeGreetingGraphqlSchema() }), };