diff --git a/library/agent/Agent.ts b/library/agent/Agent.ts index b5bf8e29..06738785 100644 --- a/library/agent/Agent.ts +++ b/library/agent/Agent.ts @@ -465,6 +465,14 @@ export class Agent { this.routes.addRoute(context); } + hasGraphQLSchema(method: string, path: string) { + return this.routes.hasGraphQLSchema(method, path); + } + + onGraphQLSchema(method: string, path: string, schema: string) { + this.routes.setGraphQLSchema(method, path, schema); + } + onGraphQLExecute( method: string, path: string, diff --git a/library/agent/Routes.test.ts b/library/agent/Routes.test.ts index 288a56c5..975a5ba2 100644 --- a/library/agent/Routes.test.ts +++ b/library/agent/Routes.test.ts @@ -40,6 +40,7 @@ t.test("it works", async (t) => { hits: 1, graphql: undefined, apispec: {}, + graphQLSchema: undefined, }, ]); @@ -53,6 +54,7 @@ t.test("it works", async (t) => { hits: 2, graphql: undefined, apispec: {}, + graphQLSchema: undefined, }, ]); @@ -66,6 +68,7 @@ t.test("it works", async (t) => { hits: 2, graphql: undefined, apispec: {}, + graphQLSchema: undefined, }, { method: "POST", @@ -73,6 +76,7 @@ t.test("it works", async (t) => { hits: 1, graphql: undefined, apispec: {}, + graphQLSchema: undefined, }, ], "Should add second route" @@ -86,6 +90,7 @@ t.test("it works", async (t) => { hits: 2, graphql: undefined, apispec: {}, + graphQLSchema: undefined, }, { method: "POST", @@ -93,6 +98,7 @@ t.test("it works", async (t) => { hits: 1, graphql: undefined, apispec: {}, + graphQLSchema: undefined, }, { method: "PUT", @@ -100,6 +106,7 @@ t.test("it works", async (t) => { hits: 1, graphql: undefined, apispec: {}, + graphQLSchema: undefined, }, ]); @@ -111,6 +118,7 @@ t.test("it works", async (t) => { hits: 2, graphql: undefined, apispec: {}, + graphQLSchema: undefined, }, { method: "PUT", @@ -118,6 +126,7 @@ t.test("it works", async (t) => { hits: 1, graphql: undefined, apispec: {}, + graphQLSchema: undefined, }, { method: "DELETE", @@ -125,6 +134,7 @@ t.test("it works", async (t) => { hits: 1, graphql: undefined, apispec: {}, + graphQLSchema: undefined, }, ]); @@ -143,6 +153,7 @@ t.test("it adds GraphQL fields", async (t) => { hits: 1, graphql: undefined, apispec: {}, + graphQLSchema: undefined, }, { method: "POST", @@ -150,6 +161,7 @@ t.test("it adds GraphQL fields", async (t) => { hits: 1, graphql: { type: "query", name: "user" }, apispec: {}, + graphQLSchema: undefined, }, ]); @@ -161,6 +173,7 @@ t.test("it adds GraphQL fields", async (t) => { hits: 1, graphql: undefined, apispec: {}, + graphQLSchema: undefined, }, { method: "POST", @@ -168,6 +181,7 @@ t.test("it adds GraphQL fields", async (t) => { hits: 2, graphql: { type: "query", name: "user" }, apispec: {}, + graphQLSchema: undefined, }, ]); @@ -179,6 +193,7 @@ t.test("it adds GraphQL fields", async (t) => { hits: 1, graphql: undefined, apispec: {}, + graphQLSchema: undefined, }, { method: "POST", @@ -186,6 +201,7 @@ t.test("it adds GraphQL fields", async (t) => { hits: 2, graphql: { type: "query", name: "user" }, apispec: {}, + graphQLSchema: undefined, }, { method: "POST", @@ -196,6 +212,7 @@ t.test("it adds GraphQL fields", async (t) => { name: "post", }, apispec: {}, + graphQLSchema: undefined, }, ]); @@ -207,6 +224,7 @@ t.test("it adds GraphQL fields", async (t) => { hits: 1, graphql: undefined, apispec: {}, + graphQLSchema: undefined, }, { method: "POST", @@ -214,6 +232,7 @@ t.test("it adds GraphQL fields", async (t) => { hits: 2, graphql: { type: "query", name: "user" }, apispec: {}, + graphQLSchema: undefined, }, { method: "POST", @@ -224,6 +243,7 @@ t.test("it adds GraphQL fields", async (t) => { name: "post", }, apispec: {}, + graphQLSchema: undefined, }, { method: "POST", @@ -234,6 +254,7 @@ t.test("it adds GraphQL fields", async (t) => { name: "post", }, apispec: {}, + graphQLSchema: undefined, }, ]); }); @@ -284,6 +305,7 @@ t.test("it adds body schema", async (t) => { query: undefined, auth: undefined, }, + graphQLSchema: undefined, }, ]); }); @@ -299,6 +321,7 @@ t.test("it merges body schema", async (t) => { hits: 1, graphql: undefined, apispec: {}, + graphQLSchema: undefined, }, ]); @@ -344,6 +367,7 @@ t.test("it merges body schema", async (t) => { }, auth: undefined, }, + graphQLSchema: undefined, }, ]); @@ -399,6 +423,7 @@ t.test("it merges body schema", async (t) => { }, auth: undefined, }, + graphQLSchema: undefined, }, ]); }); @@ -430,6 +455,7 @@ t.test("it adds query schema", async (t) => { }, }, }, + graphQLSchema: undefined, }, ]); }); @@ -445,6 +471,7 @@ t.test("it merges query schema", async (t) => { hits: 1, graphql: undefined, apispec: {}, + graphQLSchema: undefined, }, ]); routes.addRoute(getContext("GET", "/query", {}, undefined, { test: "abc" })); @@ -473,6 +500,7 @@ t.test("it merges query schema", async (t) => { }, auth: undefined, }, + graphQLSchema: undefined, }, ]); }); @@ -499,6 +527,7 @@ t.test("it adds auth schema", async (t) => { query: undefined, auth: [{ type: "http", scheme: "bearer" }], }, + graphQLSchema: undefined, }, { method: "GET", @@ -510,6 +539,7 @@ t.test("it adds auth schema", async (t) => { query: undefined, auth: [{ type: "apiKey", in: "cookie", name: "session" }], }, + graphQLSchema: undefined, }, { method: "GET", @@ -521,6 +551,7 @@ t.test("it adds auth schema", async (t) => { query: undefined, auth: [{ type: "apiKey", in: "header", name: "x-api-key" }], }, + graphQLSchema: undefined, }, ]); }); @@ -549,6 +580,7 @@ t.test("it merges auth schema", async (t) => { { type: "apiKey", in: "header", name: "x-api-key" }, ], }, + graphQLSchema: undefined, }, ]); }); @@ -563,6 +595,7 @@ t.test("it ignores empty body objects", async (t) => { hits: 1, graphql: undefined, apispec: {}, + graphQLSchema: undefined, }, ]); }); @@ -598,6 +631,7 @@ t.test("it ignores body of graphql queries", async (t) => { query: undefined, auth: [{ type: "apiKey", in: "header", name: "x-api-key" }], }, + graphQLSchema: undefined, }, ]); }); @@ -681,6 +715,7 @@ t.test("it respects max samples", async (t) => { query: undefined, auth: undefined, }, + graphQLSchema: undefined, }, ]); }); @@ -712,6 +747,7 @@ t.test( hits: 12, graphql: undefined, apispec: {}, + graphQLSchema: undefined, }, ]); } @@ -750,6 +786,7 @@ t.test("with string format", async (t) => { query: undefined, auth: undefined, }, + graphQLSchema: undefined, }, ]); }); @@ -798,6 +835,7 @@ t.test( query: undefined, auth: undefined, }, + graphQLSchema: undefined, }, ]); @@ -830,6 +868,7 @@ t.test( query: undefined, auth: undefined, }, + graphQLSchema: undefined, }, ]); } diff --git a/library/agent/Routes.ts b/library/agent/Routes.ts index 4a6c2ede..6ad4e93f 100644 --- a/library/agent/Routes.ts +++ b/library/agent/Routes.ts @@ -13,9 +13,14 @@ export type Route = { }; export class Routes { + // Routes are only registered at the end of the request, so we need to store the schema in a separate map + private graphQLSchemas: Map = new Map(); private routes: Map = new Map(); - constructor(private readonly maxEntries: number = 1000) {} + constructor( + private readonly maxEntries: number = 1000, + private readonly maxGraphQLSchemas = 10 + ) {} addRoute(context: Context) { if (isAikidoDASTRequest(context)) { @@ -63,6 +68,22 @@ export class Routes { return `${method}:${path}`; } + hasGraphQLSchema(method: string, path: string): boolean { + const key = this.getKey(method, path); + + return this.graphQLSchemas.has(key); + } + + setGraphQLSchema(method: string, path: string, schema: string) { + if ( + schema.length > 0 && + this.graphQLSchemas.size < this.maxGraphQLSchemas + ) { + const key = this.getKey(method, path); + this.graphQLSchemas.set(key, schema); + } + } + private getGraphQLKey( method: string, path: string, @@ -124,6 +145,7 @@ export class Routes { hits: route.hits, graphql: route.graphql, apispec: route.apispec, + graphQLSchema: this.graphQLSchemas.get(key), }; }); } diff --git a/library/sources/Express.test.ts b/library/sources/Express.test.ts index c2dacf57..83ccbd8b 100644 --- a/library/sources/Express.test.ts +++ b/library/sources/Express.test.ts @@ -288,6 +288,7 @@ t.test("it adds body schema to stored routes", async (t) => { query: undefined, auth: undefined, }, + graphQLSchema: undefined, }, ]); }); diff --git a/library/sources/GraphQL.schema.test.ts b/library/sources/GraphQL.schema.test.ts new file mode 100644 index 00000000..085af714 --- /dev/null +++ b/library/sources/GraphQL.schema.test.ts @@ -0,0 +1,139 @@ +import * as t from "tap"; +import { ReportingAPIForTesting } from "../agent/api/ReportingAPIForTesting"; +import { runWithContext } from "../agent/Context"; +import { GraphQL } from "./GraphQL"; +import { Token } from "../agent/api/Token"; +import { createTestAgent } from "../helpers/createTestAgent"; + +function getTestContext() { + return { + remoteAddress: "::1", + method: "POST", + url: "http://localhost:4000/graphql", + query: {}, + headers: {}, + body: {}, + cookies: {}, + routeParams: {}, + source: "express", + route: "/graphql", + }; +} + +t.test("it works", async () => { + const api = new ReportingAPIForTesting(); + const agent = createTestAgent({ + api: api, + token: new Token("123"), + }); + + agent.start([new GraphQL()]); + + const { graphql, buildSchema } = + require("graphql") as typeof import("graphql"); + + const dsl = ` +type Mutation { + createBook(title: String!, authorId: ID!): Book! + createAuthor(name: String!): Author! +} + +type Query { + getBook(id: ID!): Book + getAuthor(id: ID!): Author +} + +type Book { + id: ID! + title: String! + author: Author! +} + +type Author { + id: ID! + name: String! + books: [Book!]! +}`.trimStart(); + + const schema = buildSchema(dsl); + + const root = { + getBook: ({ id }: { id: string }) => { + return { + id: id, + title: "Book Title", + author: { + id: "1", + name: "Author Name", + books: [], + }, + }; + }, + }; + + const query = async (variableValues: Record) => { + return await graphql({ + schema, + source: ` + query getBook($id: ID!) { + getBook(id: $id) { + id + title + author { + id + name + books { + id + title + } + } + } + } + `, + rootValue: root, + variableValues: variableValues, + }); + }; + + api.clear(); + + const hits = 3; + + await runWithContext(getTestContext(), async () => { + for (let i = 0; i < hits; i++) { + await query({ id: "1" }); + + // Route is registered at the end of the request + agent.onRouteExecute(getTestContext()); + } + }); + + await agent.flushStats(1000); + + t.same(api.getEvents().length, 1); + const [heartbeat] = api.getEvents(); + t.match(heartbeat, { + type: "heartbeat", + routes: [ + { + method: "POST", + path: "/graphql", + hits: hits, + graphql: { + type: "query", + name: "getBook", + }, + apispec: {}, + graphQLSchema: undefined, + }, + { + method: "POST", + path: "/graphql", + hits: hits, + graphql: undefined, + apispec: {}, + graphQLSchema: dsl, + }, + ], + }); +}); diff --git a/library/sources/GraphQL.ts b/library/sources/GraphQL.ts index a34ca7b0..73a1d450 100644 --- a/library/sources/GraphQL.ts +++ b/library/sources/GraphQL.ts @@ -14,6 +14,51 @@ import { wrapExport } from "../agent/hooks/wrapExport"; export class GraphQL implements Wrapper { private graphqlModule: typeof import("graphql") | undefined; + private discoverGraphQLSchema( + method: string, + route: string, + executeArgs: ExecutionArgs, + agent: Agent + ) { + if (!this.graphqlModule) { + return; + } + + if (!executeArgs.schema) { + return; + } + + if (!agent.hasGraphQLSchema(method, route)) { + try { + const schema = this.graphqlModule.printSchema(executeArgs.schema); + agent.onGraphQLSchema(method, route, schema); + } catch (e) { + // Ignore errors + } + } + } + + private discoverGraphQLQueryFields( + method: string, + route: string, + executeArgs: ExecutionArgs, + agent: Agent + ) { + const topLevelFields = extractTopLevelFieldsFromDocument( + executeArgs.document, + executeArgs.operationName ? executeArgs.operationName : undefined + ); + + if (topLevelFields) { + agent.onGraphQLExecute( + method, + route, + topLevelFields.type, + topLevelFields.fields.map((field) => field.name.value) + ); + } + } + private inspectGraphQLExecute(args: unknown[], agent: Agent): void { if ( !Array.isArray(args) || @@ -32,19 +77,18 @@ export class GraphQL implements Wrapper { } if (context.method && context.route) { - const topLevelFields = extractTopLevelFieldsFromDocument( - executeArgs.document, - executeArgs.operationName ? executeArgs.operationName : undefined + this.discoverGraphQLSchema( + context.method, + context.route, + executeArgs, + agent + ); + this.discoverGraphQLQueryFields( + context.method, + context.route, + executeArgs, + agent ); - - if (topLevelFields) { - agent.onGraphQLExecute( - context.method, - context.route, - topLevelFields.type, - topLevelFields.fields.map((field) => field.name.value) - ); - } } const userInputs = extractInputsFromDocument( diff --git a/library/sources/HTTP2Server.test.ts b/library/sources/HTTP2Server.test.ts index 1792c90e..b0702e13 100644 --- a/library/sources/HTTP2Server.test.ts +++ b/library/sources/HTTP2Server.test.ts @@ -5,8 +5,6 @@ import { ReportingAPIForTesting } from "../agent/api/ReportingAPIForTesting"; import { getContext } from "../agent/Context"; import { HTTPServer } from "./HTTPServer"; import { isLocalhostIP } from "../helpers/isLocalhostIP"; -import { wrap } from "../helpers/wrap"; -import * as pkg from "../helpers/isPackageInstalled"; import { resolve } from "path"; import { FileSystem } from "../sinks/FileSystem"; import { createTestAgent } from "../helpers/createTestAgent"; @@ -192,6 +190,7 @@ t.test("it discovers routes", async () => { hits: 1, graphql: undefined, apispec: {}, + graphQLSchema: undefined, } ); server.close();