Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 2 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -36,6 +37,7 @@ export const routes = buildRouteMap({
"data-connection": dataConnectionRoutes,
datastream: datastreamRoutes,
"datastream-token": datastreamTokenRoutes,
schema: schemaRoutes,
cli: cliRoutes,
},
defaultCommand: "help",
Expand Down
17 changes: 17 additions & 0 deletions src/commands/schema/index.ts
Original file line number Diff line number Diff line change
@@ -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"),
},
});
221 changes: 221 additions & 0 deletions src/commands/schema/introspect.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
96 changes: 96 additions & 0 deletions src/commands/schema/introspect.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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",
},
});
Loading