import { Context, Data, Effect, Layer, Predicate, Ref, Schema as EffectSchema, Scope, } from "effect"; import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; import type { GraphQLSchema } from "graphql"; import { createSchema, createYoga, type GraphQLParams, type YogaInitialContext, } from "graphql-yoga"; import { OAuthTestServer, 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; readonly auth?: { readonly validateAuthorization: (authorization: string | null) => Effect.Effect; readonly wwwAuthenticate?: 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* () { if (options.auth) { const accepted = yield* options.auth.validateAuthorization( request.headers.authorization ?? null, ); if (!accepted) { const responseOptions = options.auth.wwwAuthenticate ? { status: 401, headers: { "www-authenticate": options.auth.wwwAuthenticate }, } : { status: 401 }; return HttpServerResponse.jsonUnsafe( { errors: [{ message: "Unauthorized" }] }, responseOptions, ); } } 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 const serveGraphqlFailureTestServer = (options: { readonly status: number; readonly body: string; readonly contentType?: string; readonly path?: string; }) => serveTestHttpApp(() => Effect.succeed( HttpServerResponse.text(options.body, { status: options.status, contentType: options.contentType ?? "text/plain", }), ), ).pipe( Effect.map((server) => ({ endpoint: server.url(options.path ?? "/graphql"), httpClientLayer: server.httpClientLayer, })), ); export class GraphqlTestServer extends Context.Service()( "@executor-js/plugin-graphql/testing/GraphqlTestServer", ) { static readonly layer = (options: GraphqlTestServerOptions) => Layer.effect(GraphqlTestServer, serveGraphqlTestServer(options)); static readonly layerWithOAuth = (options: Omit) => Layer.effect( GraphqlTestServer, Effect.gen(function* () { const oauth = yield* OAuthTestServer; return yield* serveGraphqlTestServer({ ...options, auth: { validateAuthorization: oauth.acceptsAuthorizationHeader, wwwAuthenticate: 'Bearer error="invalid_token"', }, }); }), ); } const stringArgument = ( args: Readonly>, key: string, fallback: string, ): string => { const value = args[key]; return typeof value === "string" ? value : fallback; }; export const makeGreetingGraphqlSchema = ( options: { readonly includeMutation?: boolean } = {}, ): GraphQLSchema => { const includeMutation = options.includeMutation ?? true; return createSchema({ typeDefs: /* GraphQL */ ` type Query { hello(name: String): String } ${ includeMutation ? /* GraphQL */ ` type Mutation { setGreeting(message: String!): String } ` : "" } `, resolvers: { Query: { hello: (_source: unknown, args: Readonly>) => `Hello ${stringArgument(args, "name", "world")}`, }, ...(includeMutation ? { Mutation: { setGreeting: (_source: unknown, args: Readonly>) => stringArgument(args, "message", ""), }, } : {}), }, }); }; // A small GitLab-shaped schema that reproduces the two failure modes from issue // #1146 when the plugin auto-generates selection sets against it: // 1. `metadata.featureFlags(names: [String!]!)` has a REQUIRED nested argument // the generator cannot fill. // 2. `currentUser` nests connections deep enough that composite leaf fields // (`author`, `assignees`) sit past the recursion depth cap, so a naive // generator emits them with no sub-selection. // graphql-yoga validates with graphql-js, exactly like the @emulators/gitlab // surface, so a generated operation that validates clean here is valid GraphQL. export const makeGitlab1146Schema = (): GraphQLSchema => createSchema({ typeDefs: /* GraphQL */ ` type Query { metadata: Metadata currentUser: User } type Metadata { version: String revision: String enterprise: Boolean featureFlags(names: [String!]!): [FeatureFlag!]! kas: Kas } type FeatureFlag { name: String enabled: Boolean } type Kas { enabled: Boolean externalUrl: String } type User { active: Boolean admin: Boolean mergeRequests: MergeRequestConnection } type MergeRequestConnection { count: Int nodes: [MergeRequest!] pageInfo: PageInfo } type PageInfo { hasNextPage: Boolean endCursor: String } type MergeRequest { id: ID title: String author: Author assignees: AssigneeConnection } type Author { name: String username: String } type AssigneeConnection { count: Int nodes: [Author!] } `, }); export const TestLayers = { greeting: () => GraphqlTestServer.layer({ schema: makeGreetingGraphqlSchema() }), greetingWithOAuth: () => GraphqlTestServer.layerWithOAuth({ schema: makeGreetingGraphqlSchema() }), };