diff --git a/AGENTS.md b/AGENTS.md index 0198696..0448fea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,7 @@ src/ │ ├── datastream/ # Datastream commands (create, list, view, update) │ ├── ingest-token/ # Ingest token commands (create, list, view, update) │ ├── metric/ # Metric commands (list, view) +│ ├── schema/ # GraphQL schema commands (introspect) │ ├── skill/ # AI agent skill commands (list, view) │ ├── tag-key/ # Tag key commands (list) │ ├── tag-value/ # Tag value commands (list) @@ -57,6 +58,7 @@ src/ │ ├── datastream/ # Datastream queries/mutations │ ├── ingest-token/ # Ingest token queries/mutations │ ├── metric/ # Metric queries +│ ├── schema/ # GraphQL introspection query │ ├── workspace/ # Workspace queries │ ├── gql-request.ts # GraphQL client/executor │ └── gql-codegen.config.ts # Codegen configuration diff --git a/README.md b/README.md index a7f6baa..d244291 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ To update installed skills after edits in this repo, run `npx skills update`. | `observe datastream view` | View a datastream by ID | | `observe datastream update` | Update a datastream | | `observe datastream-token check-status` | Poll a datastream token until ingest data arrives | +| `observe schema introspect` | Run a GraphQL introspection query and print the schema | | `observe cli install` | Configure shell integration (PATH, completions) | | `observe cli uninstall` | Remove shell integration | | `observe cli upgrade` | Upgrade to the latest version | diff --git a/src/app.ts b/src/app.ts index a51b600..227ef92 100644 --- a/src/app.ts +++ b/src/app.ts @@ -13,6 +13,7 @@ import { helpCommand } from "./commands/help.js"; import { ingestTokenRoutes } from "./commands/ingest-token/index.js"; import { metricRoutes } from "./commands/metric/index.js"; import { queryCommand } from "./commands/query.js"; +import { schemaRoutes } from "./commands/schema/index.js"; import { skillRoutes } from "./commands/skill/index.js"; import { tagKeyRoutes } from "./commands/tag-key/index.js"; import { tagValueRoutes } from "./commands/tag-value/index.js"; @@ -36,6 +37,7 @@ export const routes = buildRouteMap({ "data-connection": dataConnectionRoutes, datastream: datastreamRoutes, "datastream-token": datastreamTokenRoutes, + schema: schemaRoutes, cli: cliRoutes, }, defaultCommand: "help", diff --git a/src/commands/schema/index.ts b/src/commands/schema/index.ts new file mode 100644 index 0000000..8b5954a --- /dev/null +++ b/src/commands/schema/index.ts @@ -0,0 +1,17 @@ +import { buildRouteMap } from "@stricli/core"; +import { introspectCommand } from "./introspect"; + +export const schemaRoutes = buildRouteMap({ + routes: { + introspect: introspectCommand, + }, + docs: { + brief: "Inspect the Observe GraphQL API schema", + fullDescription: [ + "Inspect the Observe GraphQL API schema via introspection.", + "", + "Commands:", + " introspect Run a GraphQL introspection query and print the schema", + ].join("\n"), + }, +}); diff --git a/src/commands/schema/introspect.test.ts b/src/commands/schema/introspect.test.ts new file mode 100644 index 0000000..f2feaac --- /dev/null +++ b/src/commands/schema/introspect.test.ts @@ -0,0 +1,221 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; +import { resolve } from "node:path"; +import type { LocalContext } from "../../context"; +import { createWriter } from "../../lib/writer"; + +const repoRoot = resolve(import.meta.dir, "../../.."); +const gqlModulePath = resolve(repoRoot, "src/gql/schema/introspect-schema.ts"); + +const loadConfigFn = mock(() => ({ + customerId: "test-customer", + token: "test-token", + domain: "observeinc.com", +})); + +const schemaPayload = { + queryType: { name: "Query" }, + mutationType: { name: "Mutation" }, + subscriptionType: null, + types: [ + { + kind: "OBJECT", + name: "Dashboard", + description: "A dashboard", + fields: [ + { + name: "id", + description: null, + isDeprecated: false, + type: { kind: "SCALAR", name: "String", ofType: null }, + args: [], + }, + ], + inputFields: null, + enumValues: null, + interfaces: [], + possibleTypes: null, + }, + { + kind: "OBJECT", + name: "Query", + description: null, + fields: [], + inputFields: null, + enumValues: null, + interfaces: [], + possibleTypes: null, + }, + ], +}; + +const introspectSchemaFn = mock((_config: unknown) => + Promise.resolve(structuredClone(schemaPayload)), +); + +let introspect: (typeof import("./introspect"))["introspect"]; + +let previousNoColor: string | undefined; +let previousForceColor: string | undefined; + +const deps = { + loadConfig: loadConfigFn, +} as Parameters<(typeof import("./introspect"))["introspect"]>[1]; + +beforeAll(async () => { + previousNoColor = process.env.NO_COLOR; + previousForceColor = process.env.FORCE_COLOR; + process.env.NO_COLOR = "1"; + process.env.FORCE_COLOR = "0"; + + void mock.module(gqlModulePath, () => ({ + introspectSchema: introspectSchemaFn, + })); + + const mod = await import("./introspect.ts"); + introspect = mod.introspect; +}); + +afterAll(() => { + mock.restore(); + if (previousNoColor === undefined) { + delete process.env.NO_COLOR; + } else { + process.env.NO_COLOR = previousNoColor; + } + if (previousForceColor === undefined) { + delete process.env.FORCE_COLOR; + } else { + process.env.FORCE_COLOR = previousForceColor; + } +}); + +function createMockContext() { + const stdout: string[] = []; + const stderr: string[] = []; + let exitCode: number | undefined; + + const processMock = { + stdout: { + write: (msg: string) => { + stdout.push(msg); + return true; + }, + }, + stderr: { + write: (msg: string) => { + stderr.push(msg); + return true; + }, + }, + exit: (code?: number) => { + exitCode = code ?? 0; + throw new Error("process.exit"); + }, + }; + + const context = { + process: processMock, + writer: createWriter({ process: processMock }), + } as unknown as LocalContext; + + return { context, stdout, stderr, getExitCode: () => exitCode }; +} + +describe("schema introspect", () => { + beforeEach(() => { + loadConfigFn.mockClear(); + introspectSchemaFn.mockClear(); + }); + + test("prints the full schema as JSON", async () => { + const { context, stdout } = createMockContext(); + await introspect.call(context, {}, deps); + + expect(introspectSchemaFn).toHaveBeenCalledTimes(1); + const output = JSON.parse(stdout.join("")); + expect(output.queryType.name).toBe("Query"); + expect(output.types).toHaveLength(2); + expect(output.types[0].name).toBe("Dashboard"); + }); + + test("filters output to a single named type with --type", async () => { + const { context, stdout } = createMockContext(); + await introspect.call(context, { type: "Dashboard" }, deps); + + const output = JSON.parse(stdout.join("")); + // The filtered output is the single type object, not the full schema. + expect(output.name).toBe("Dashboard"); + expect(output.kind).toBe("OBJECT"); + expect(output.queryType).toBeUndefined(); + }); + + test("exits with code 1 when --type is not found", async () => { + const { context, stderr, getExitCode } = createMockContext(); + try { + await introspect.call(context, { type: "NonExistentType" }, deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("not found"); + }); + + test("surfaces a clear message when introspection is disabled", async () => { + introspectSchemaFn.mockImplementationOnce(() => { + const err = new Error( + "introspection is not enabled on this tenant", + ) as Error & { statusCode: number }; + err.name = "GqlApiError"; + err.statusCode = 200; + // Make it an instance of GqlApiError so the command's instanceof check + // matches. Importing the real class keeps the prototype chain intact. + return Promise.reject(makeGqlApiError(err.message)); + }); + + const { context, stderr, getExitCode } = createMockContext(); + try { + await introspect.call(context, {}, deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("introspection is disabled"); + }); + + test("exits with code 1 on a generic API error", async () => { + introspectSchemaFn.mockImplementationOnce(() => + Promise.reject(makeGqlApiError("Server error", 500)), + ); + + const { context, stderr, getExitCode } = createMockContext(); + try { + await introspect.call(context, {}, deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("API Error (500)"); + }); +}); + +/** + * Build an object that satisfies the command's `instanceof GqlApiError` check + * without importing the gql-request module (which would pull in unrelated + * code). We construct the real class lazily via the module the command uses. + */ +function makeGqlApiError(message: string, statusCode = 200) { + const { GqlApiError } = + require("../../gql/gql-request") as typeof import("../../gql/gql-request"); + return new GqlApiError(message, statusCode); +} diff --git a/src/commands/schema/introspect.ts b/src/commands/schema/introspect.ts new file mode 100644 index 0000000..71aefaf --- /dev/null +++ b/src/commands/schema/introspect.ts @@ -0,0 +1,96 @@ +import { buildCommand } from "@stricli/core"; +import type { LocalContext } from "../../context"; +import { + introspectSchema, + type IntrospectionType, +} from "../../gql/schema/introspect-schema"; +import { GqlApiError } from "../../gql/gql-request"; +import { loadConfig } from "../../lib/config"; + +interface IntrospectFlags { + type?: string; +} + +export interface IntrospectDeps { + loadConfig?: typeof loadConfig; + introspectSchema?: typeof introspectSchema; +} + +/** + * Detect the server's "introspection disabled" error so we can surface a clear + * message. Tenants that disable introspection reject the query with an error + * mentioning that introspection is not enabled. + */ +function isIntrospectionDisabled(error: GqlApiError): boolean { + return /introspection/i.test(error.message); +} + +export async function introspect( + this: LocalContext, + flags: IntrospectFlags, + deps: IntrospectDeps = {}, +): Promise { + const { + loadConfig: loadConfigImpl = loadConfig, + introspectSchema: introspectSchemaImpl = introspectSchema, + } = deps; + const { process, writer } = this; + + try { + const config = loadConfigImpl(); + const schema = await introspectSchemaImpl(config); + + if (flags.type) { + const match = schema.types.find( + (t: IntrospectionType) => t.name === flags.type, + ); + if (!match) { + writer.error( + `Error: schema introspect: type "${flags.type}" not found`, + ); + process.exit(1); + return; + } + writer.write(JSON.stringify(match, null, 2)); + return; + } + + writer.write(JSON.stringify(schema, null, 2)); + } catch (error) { + if (error instanceof GqlApiError) { + if (isIntrospectionDisabled(error)) { + writer.error( + "Error: GraphQL introspection is disabled on this tenant. " + + "This command requires a tenant with introspection enabled.", + ); + } else { + writer.error(`API Error (${error.statusCode}): ${error.message}`); + } + } else { + const message = error instanceof Error ? error.message : String(error); + writer.error(`Error: ${message}`); + } + process.exit(1); + } +} + +export const introspectCommand = buildCommand({ + loader: async () => introspect, + parameters: { + positional: { + kind: "tuple", + parameters: [], + }, + flags: { + type: { + kind: "parsed", + parse: String, + brief: "Filter output to a specific type name (e.g. Dashboard, Query)", + optional: true, + }, + }, + }, + docs: { + brief: "Run a GraphQL introspection query and print the schema as JSON", + }, +}); diff --git a/src/gql/schema/introspect-schema.graphql b/src/gql/schema/introspect-schema.graphql new file mode 100644 index 0000000..7272ee8 --- /dev/null +++ b/src/gql/schema/introspect-schema.graphql @@ -0,0 +1,76 @@ +query Schema_Introspect { + __schema { + queryType { + name + } + mutationType { + name + } + subscriptionType { + name + } + types { + kind + name + description + fields(includeDeprecated: true) { + name + description + isDeprecated + type { + kind + name + ofType { + kind + name + ofType { + kind + name + } + } + } + args { + name + description + type { + kind + name + ofType { + kind + name + } + } + } + } + inputFields { + name + description + type { + kind + name + ofType { + kind + name + ofType { + kind + name + } + } + } + } + enumValues(includeDeprecated: true) { + name + description + isDeprecated + } + interfaces { + name + kind + } + possibleTypes { + name + kind + } + } + } +} diff --git a/src/gql/schema/introspect-schema.ts b/src/gql/schema/introspect-schema.ts new file mode 100644 index 0000000..12753d4 --- /dev/null +++ b/src/gql/schema/introspect-schema.ts @@ -0,0 +1,129 @@ +import { getApiBaseUrl, type Config } from "../../lib/config"; +import { observeApiHeaders } from "../../lib/user-agent"; +import { GqlApiError } from "../gql-request"; + +/** + * Raw GraphQL introspection query, mirroring the query used by the legacy Go + * CLI (`cmd_schema.go`). This intentionally does NOT depend on the codegen'd + * types under `../generated/graphql` because it queries the server's + * `__schema` meta type directly rather than a domain operation. + * + * Keep this in sync with `introspect-schema.graphql`. + */ +const INTROSPECTION_QUERY = `query Schema_Introspect { + __schema { + queryType { name } + mutationType { name } + subscriptionType { name } + types { + kind + name + description + fields(includeDeprecated: true) { + name + description + isDeprecated + type { + kind + name + ofType { kind name ofType { kind name } } + } + args { + name + description + type { kind name ofType { kind name } } + } + } + inputFields { + name + description + type { + kind + name + ofType { kind name ofType { kind name } } + } + } + enumValues(includeDeprecated: true) { + name + description + isDeprecated + } + interfaces { name kind } + possibleTypes { name kind } + } + } +}`; + +/** A single named type entry from `__schema.types`. */ +export interface IntrospectionType { + kind: string; + name: string | null; + description: string | null; + fields: unknown[] | null; + inputFields: unknown[] | null; + enumValues: unknown[] | null; + interfaces: unknown[] | null; + possibleTypes: unknown[] | null; +} + +/** The `__schema` payload returned by the introspection query. */ +export interface IntrospectionSchema { + queryType: { name: string | null } | null; + mutationType: { name: string | null } | null; + subscriptionType: { name: string | null } | null; + types: IntrospectionType[]; +} + +interface IntrospectionResponse { + data?: { __schema?: IntrospectionSchema }; + errors?: { message: string }[]; +} + +/** + * Run the GraphQL introspection query against the Observe `/v1/meta` endpoint + * and return the `__schema` payload. + * + * Mirrors the auth/header approach of `executeGraphQL` in `gql-request.ts` but + * issues the raw introspection query directly so it does not require any + * generated `TypedDocumentNode`. + */ +export async function introspectSchema( + config: Config, +): Promise { + const baseUrl = getApiBaseUrl(config); + const url = `${baseUrl}/v1/meta`; + + const response = await fetch(url, { + method: "POST", + headers: observeApiHeaders({ + Authorization: `Bearer ${config.customerId} ${config.token}`, + "Content-Type": "application/json", + Accept: "application/json", + }), + body: JSON.stringify({ query: INTROSPECTION_QUERY }), + }); + + if (!response.ok) { + throw new GqlApiError( + `GraphQL request failed: ${response.status} ${response.statusText}`, + response.status, + ); + } + + const json = (await response.json()) as IntrospectionResponse; + + if (json.errors?.length) { + throw new GqlApiError( + json.errors.map((e) => e.message).join(", "), + 200, + json.errors, + ); + } + + const schema = json.data?.__schema; + if (!schema) { + throw new Error("schema introspect: no result returned"); + } + + return schema; +}