From 08db8518805c5f915aa1b82f144b2d878336d6be Mon Sep 17 00:00:00 2001 From: Roy de Kleijn Date: Tue, 8 Sep 2026 21:53:26 +0200 Subject: [PATCH] feat: robust GraphQL query builder, SOAP fixes, faster startup GraphQL: - Build/insert queries via the AST (parse -> insert -> print) so an insert from the schema explorer is always valid syntax; required args become typed variables (with signature + variables JSON kept in sync), objects/unions get valid selection sets. - Only add truly-required args: honor default values (first: Int! = 10 is optional), inline required pagination as a number, "all fields" action, "hide fields that require arguments" filter, and mark required args with !. - Live schema validation status; fetch arg defaultValue in introspection. - Fix schema-explorer vertical scrolling. SOAP: - Disambiguate same-named operations across 1.1/1.2 bindings (persist soapVersion + binding); fix the detail panel scrolling. Other: - Response table row numbers start at 1. - Cmd/Ctrl+Enter (re)sends the active request. - Faster startup: drop the fixed 1.2s splash delay (min-visible time instead) and lazy-load the GraphQL/SOAP editors (-448 kB from the initial chunk). --- agent/package.json | 2 +- package-lock.json | 4 +- package.json | 2 +- src/main/index.ts | 12 +- src/renderer/src/App.tsx | 9 +- .../src/components/RequestBuilder/BodyTab.tsx | 16 +- .../RequestBuilder/GraphQLEditor.tsx | 148 ++++++---- .../RequestBuilder/RequestBuilder.tsx | 5 +- .../components/RequestBuilder/SoapEditor.tsx | 21 +- .../ResponseViewer/ResponseTable.tsx | 2 +- src/renderer/src/lib/graphql-introspection.ts | 12 +- src/renderer/src/lib/graphql-query-builder.ts | 266 ++++++++++++++++++ src/shared/types/http.ts | 5 + src/tests/graphql-query-builder.test.ts | 137 +++++++++ 14 files changed, 570 insertions(+), 71 deletions(-) create mode 100644 src/renderer/src/lib/graphql-query-builder.ts create mode 100644 src/tests/graphql-query-builder.test.ts diff --git a/agent/package.json b/agent/package.json index 5c914c8..c285e14 100644 --- a/agent/package.json +++ b/agent/package.json @@ -1,6 +1,6 @@ { "name": "@testsmith/api-spector-agent", - "version": "0.5.1", + "version": "0.5.2", "description": "API Spector private runner: monitor internal/local APIs the cloud cannot reach, with no inbound connections.", "license": "MIT", "homepage": "https://api-spector.dev", diff --git a/package-lock.json b/package-lock.json index 237b25b..3374c68 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@testsmith/api-spector", - "version": "0.5.1", + "version": "0.5.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@testsmith/api-spector", - "version": "0.5.1", + "version": "0.5.2", "license": "MIT", "dependencies": { "@codemirror/commands": "^6.10.3", diff --git a/package.json b/package.json index e1dbafd..26c9160 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@testsmith/api-spector", "productName": "API Spector", - "version": "0.5.1", + "version": "0.5.2", "description": "Local-first API testing tool to inspect, test and mock APIs", "repository": { "type": "git", diff --git a/src/main/index.ts b/src/main/index.ts index c009aab..92e6a23 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -86,7 +86,13 @@ function loadAppIcon(): Electron.NativeImage | undefined { } +// Keep the splash on screen at least this long so it reads as intentional, but +// never add a fixed delay on top of load time (it used to always wait 1.2s +// AFTER the window finished loading). +const MIN_SPLASH_MS = 350; + function createWindow(): void { + const startedAt = Date.now(); const splash = createSplashWindow(); const appIcon = loadAppIcon(); @@ -119,11 +125,13 @@ function createWindow(): void { } win.webContents.once('did-finish-load', () => { - // Brief pause so the splash is visible even on fast machines + // Show as soon as the window is loaded; only hold back for whatever remains + // of the minimum splash time (0 once load already took that long). + const remaining = Math.max(0, MIN_SPLASH_MS - (Date.now() - startedAt)); setTimeout(() => { splash.close(); win.show(); - }, 1200); + }, remaining); }); // On Windows the native title bar is shown — include the version in the title diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index f2e0f86..ec1404a 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -208,6 +208,7 @@ export default function App () { const updateMock = useStore( s => s.updateMock ); const theme = useStore( s => s.theme ); const setCommandPaletteOpen = useStore( s => s.setCommandPaletteOpen ); + const requestSend = useStore( s => s.requestSend ); const setWsStatus = useStore( s => s.setWsStatus ); const addWsMessage = useStore( s => s.addWsMessage ); const pushLiveStreamEvents = useStore( s => s.pushLiveStreamEvents ); @@ -283,10 +284,16 @@ export default function App () { e.preventDefault(); setCommandPaletteOpen( true ); } + // Cmd/Ctrl+Enter (re)sends the active request. The active RequestBuilder + // handles the actual send and ignores it for WebSocket/gRPC tabs. + if ( e.key === 'Enter' && ( e.metaKey || e.ctrlKey ) ) { + e.preventDefault(); + requestSend(); + } } window.addEventListener( 'keydown', handleKeyDown ); return () => window.removeEventListener( 'keydown', handleKeyDown ); - }, [setCommandPaletteOpen] ); + }, [setCommandPaletteOpen, requestSend] ); // Keep light class in sync when OS preference changes (system theme) useEffect( () => { diff --git a/src/renderer/src/components/RequestBuilder/BodyTab.tsx b/src/renderer/src/components/RequestBuilder/BodyTab.tsx index 5a64952..86d8347 100644 --- a/src/renderer/src/components/RequestBuilder/BodyTab.tsx +++ b/src/renderer/src/components/RequestBuilder/BodyTab.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2024-2026 Testsmith.io // SPDX-License-Identifier: MIT -import React, { useMemo } from 'react'; +import React, { useMemo, lazy, Suspense } from 'react'; import CodeMirror from '@uiw/react-codemirror'; import { oneDark } from '@codemirror/theme-one-dark'; import type { ApiRequest, RequestBody } from '../../../../shared/types'; @@ -10,8 +10,12 @@ import { varCompletionExtension, varHoverTooltipExtension } from './atCompletion import { jsonWithComments, xmlWithComments, commentKeymap } from './commentKeymap'; import { useVarNames } from '../../hooks/useVarNames'; import { useVarValues } from '../../hooks/useVarValues'; -import { GraphQLEditor } from './GraphQLEditor'; -import { SoapEditor } from './SoapEditor'; + +// GraphQL (graphql + cm6-graphql) and SOAP (WSDL/XML) editors are heavy and only +// used for those body modes, so they load on demand to keep startup lean. +const GraphQLEditor = lazy(() => import('./GraphQLEditor').then(m => ({ default: m.GraphQLEditor }))); +const SoapEditor = lazy(() => import('./SoapEditor').then(m => ({ default: m.SoapEditor }))); +const EditorFallback =
Loading editor…
; type BodyMode = RequestBody['mode'] @@ -36,7 +40,7 @@ export function BodyTab({ request, onChange }: { request: ApiRequest; onChange: if (isSoap) { return (
- +
); } @@ -126,13 +130,13 @@ export function BodyTab({ request, onChange }: { request: ApiRequest; onChange: {mode === 'graphql' && (
- +
)} {mode === 'soap' && (
- +
)} diff --git a/src/renderer/src/components/RequestBuilder/GraphQLEditor.tsx b/src/renderer/src/components/RequestBuilder/GraphQLEditor.tsx index 7357460..385d4a5 100644 --- a/src/renderer/src/components/RequestBuilder/GraphQLEditor.tsx +++ b/src/renderer/src/components/RequestBuilder/GraphQLEditor.tsx @@ -17,11 +17,17 @@ import { displayType, getBaseTypeName, getBaseKind, - buildSnippet, - insertSnippet, + argRequired, parseIntrospection, fetchSchemaFromUrl, } from '../../lib/graphql-introspection'; +import { insertField, validateQuery, type OperationType } from '../../lib/graphql-query-builder'; + +type InsertHandler = (field: GqlField, path: string[], opType: OperationType, allFields?: boolean) => void; + +function opTypeForLabel(label: string): OperationType { + return label === 'Mutation' ? 'mutation' : label === 'Subscription' ? 'subscription' : 'query'; +} // ─── Schema explorer components ────────────────────────────────────────────── @@ -29,12 +35,17 @@ function FieldNode({ field, typeMap, depth, + path, + opType, onInsert, }: { field: GqlField typeMap: Map depth: number - onInsert: (snippet: string, parentField?: string) => void + /** Ancestor field names from the operation root to this field's parent. */ + path: string[] + opType: OperationType + onInsert: InsertHandler }) { const [expanded, setExpanded] = useState(false); const baseTypeName = getBaseTypeName(field.type); @@ -43,15 +54,6 @@ function FieldNode({ const nestedType = typeMap.get(baseTypeName); const hasChildren = isObject && !!nestedType?.fields?.length; - // When a child field is clicked, pass it up with the parent context. - // If the child already carries a parentField (from a deeper nesting level), - // pass it through unchanged — the deeper child knows which block it needs. - // Only set this field's name as parentField if the child didn't provide one - // (i.e. the child is a direct leaf of this field). - const childInsert = useCallback((childSnippet: string, childParent?: string) => { - onInsert(childSnippet, childParent ?? field.name); - }, [field.name, onInsert]); - return (
{field.name} - {field.args.length > 0 && ( - a.name).join(', ')}> - ({field.args.map(a => a.name).join(', ')}) - - )} + {field.args.length > 0 && (() => { + const hasRequired = field.args.some(argRequired); + // Required args get a trailing ! so it's clear the field needs input + // (e.g. brand(id!) vs brands()). + const label = field.args.map(a => a.name + (argRequired(a) ? "!" : "")).join(', '); + const full = field.args.map(a => `${a.name}: ${displayType(a.type)}`).join(', '); + return ( + + ({label}) + + ); + })()} {displayType(field.type)} + {hasChildren && ( + + )}
{expanded && hasChildren && nestedType!.fields!.map(f => ( - + ))}
); @@ -102,16 +120,22 @@ function RootTypeSection({ label, typeName, typeMap, + listOnly, onInsert, }: { label: string typeName: string typeMap: Map - onInsert: (snippet: string, parentField?: string) => void + /** Hide root fields that require an argument (the by-id lookups), leaving the + * list/collection fields. */ + listOnly: boolean + onInsert: InsertHandler }) { const [expanded, setExpanded] = useState(true); const type = typeMap.get(typeName); - if (!type?.fields?.length) return null; + const opType = opTypeForLabel(label); + const fields = (type?.fields ?? []).filter(f => !listOnly || !f.args.some(argRequired)); + if (!fields.length) return null; return (
@@ -121,10 +145,10 @@ function RootTypeSection({ > {expanded ? '▾' : '▸'} {label} - {type.fields.length} fields + {fields.length} fields - {expanded && type.fields.map(f => ( - + {expanded && fields.map(f => ( + ))}
); @@ -135,13 +159,13 @@ function SchemaExplorer({ onInsert, }: { schema: ParsedSchema - onInsert: (snippet: string, parentField?: string) => void + onInsert: InsertHandler }) { const [search, setSearch] = useState(''); + const [listOnly, setListOnly] = useState(false); const filter = search.trim().toLowerCase(); - - function filteredInsert(snippet: string, parentField?: string) { onInsert(snippet, parentField); } + const passesListOnly = (f: GqlField) => !listOnly || !f.args.some(argRequired); // When searching, show a flat filtered list across all root type fields const rootTypeNames = [schema.queryType, schema.mutationType, schema.subscriptionType].filter(Boolean) as string[]; @@ -153,27 +177,31 @@ function SchemaExplorer({ const label = typeName === schema.queryType ? 'Query' : typeName === schema.mutationType ? 'Mutation' : 'Subscription'; for (const f of type?.fields ?? []) { - if (f.name.toLowerCase().includes(filter)) allFields.push({ rootLabel: label, field: f }); + if (f.name.toLowerCase().includes(filter) && passesListOnly(f)) allFields.push({ rootLabel: label, field: f }); } } } return ( -
-
+
+
setSearch(e.target.value)} placeholder="Search fields…" className="w-full bg-surface-800 border border-surface-700 rounded px-2 py-0.5 text-[11px] focus:outline-none focus:border-blue-500 placeholder-surface-700" /> +
{filter ? ( allFields.length > 0 ? allFields.map(({ rootLabel, field }) => (
- +
)) : (

No fields match "{search}"

@@ -181,13 +209,13 @@ function SchemaExplorer({ ) : ( <> {schema.queryType && ( - + )} {schema.mutationType && ( - + )} {schema.subscriptionType && ( - + )} )} @@ -280,9 +308,20 @@ export function GraphQLEditor({ request, onChange }: Props) { onChange({ body: { ...request.body, graphql: { ...gql, ...patch } } }); } - const handleInsert = useCallback((snippet: string, parentField?: string) => { - onChange({ body: { ...request.body, graphql: { ...gql, query: insertSnippet(gql.query, snippet, parentField) } } }); - }, [gql, onChange, request.body]); + // Insert a field from the explorer by editing the query AST and printing it, + // so the result is always valid: the field lands in the right operation, its + // required args become typed variables (added to the signature + seeded into + // the variables JSON), and object/union fields get a valid selection set. + const handleInsert = useCallback((field, path, opType, allFields) => { + if (!schema) return; + const { query, variables } = insertField(gql.query, gql.variables, schema, opType, path, field, gql.operationName || undefined, { allFields }); + onChange({ body: { ...request.body, graphql: { ...gql, query, variables } } }); + }, [gql, onChange, request.body, schema]); + + // Live validation against the schema, plus a used-but-unset variable check. + const problems = useMemo(() => validateQuery(gqlSchema, gql.query, gql.variables), [gqlSchema, gql.query, gql.variables]); + const errors = problems.filter(p => p.severity === 'error'); + const warnings = problems.filter(p => p.severity === 'warning'); async function loadSchema() { const url = request.url.trim(); @@ -373,7 +412,7 @@ export function GraphQLEditor({ request, onChange }: Props) {
{/* Schema explorer */} {hasSchema && showExplorer && ( -
+
Schema
@@ -384,8 +423,15 @@ export function GraphQLEditor({ request, onChange }: Props) { {/* Right: query + variables */}
{/* Query editor */} -
-
+
+
+ {!gql.query.trim() ? : errors.length ? ( + e.message).join('\n')}>✗ {errors.length} error{errors.length !== 1 ? 's' : ''} + ) : warnings.length ? ( + w.message).join('\n')}>▲ {warnings.length} warning{warnings.length !== 1 ? 's' : ''} + ) : ( + ✓ valid + )}
- updateGql({ query: val })} - placeholder="query {\n # your query here\n}" - basicSetup={{ lineNumbers: true, foldGutter: true, bracketMatching: true, autocompletion: !gqlSchema }} - /> +
+ updateGql({ query: val })} + placeholder="query {\n # your query here\n}" + basicSetup={{ lineNumbers: true, foldGutter: true, bracketMatching: true, autocompletion: !gqlSchema }} + /> +
{/* Variables section */} diff --git a/src/renderer/src/components/RequestBuilder/RequestBuilder.tsx b/src/renderer/src/components/RequestBuilder/RequestBuilder.tsx index b35e796..2766b2f 100644 --- a/src/renderer/src/components/RequestBuilder/RequestBuilder.tsx +++ b/src/renderer/src/components/RequestBuilder/RequestBuilder.tsx @@ -136,7 +136,9 @@ export function RequestBuilder({ request }: Props) { useEffect(() => { if (sendSignal !== lastSendSignal.current) { lastSendSignal.current = sendSignal; - void sendRequest(); + // WebSocket / gRPC drive their own panels; the HTTP send pipeline does not + // apply, so the Cmd/Ctrl+Enter shortcut is a no-op for them. + if (request.protocol !== 'websocket' && request.protocol !== 'grpc') void sendRequest(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [sendSignal]); @@ -506,6 +508,7 @@ export function RequestBuilder({ request }: Props) {
{operations.map(op => { - const active = selected && op.name === selected.name && op.soapVersion === selected.soapVersion; + const active = selected && op.name === selected.name && op.soapVersion === selected.soapVersion && (op.binding ?? '') === (selected.binding ?? ''); return (
- {/* Detail */} -
+ {/* Detail (scrolls independently of the operations list) */} +
{selected && ( <> {/* Operation header */} @@ -261,7 +272,7 @@ export function SoapEditor({ request, onChange }: Props) {

)} {showXml && ( -
+
- {i} + {i + 1} {isPrimitiveRows ? ( drill(i, null, row)} /> ) : ( diff --git a/src/renderer/src/lib/graphql-introspection.ts b/src/renderer/src/lib/graphql-introspection.ts index fbdcbde..d31450f 100644 --- a/src/renderer/src/lib/graphql-introspection.ts +++ b/src/renderer/src/lib/graphql-introspection.ts @@ -15,6 +15,9 @@ export interface GqlArg { name: string type: GqlTypeRef description?: string | null + /** The argument's default value as a GraphQL literal string, or null. A + * non-null arg WITH a default is optional to provide (the default applies). */ + defaultValue?: string | null } export interface GqlField { @@ -67,6 +70,13 @@ export function isRequired(ref: GqlTypeRef | null): boolean { return !!ref && ref.kind === 'NON_NULL'; } +/** Whether an argument must actually be supplied: non-null type AND no default + * value. `first: Int! = 10` is non-null but optional (the default applies), so + * it is NOT required. */ +export function argRequired(arg: GqlArg): boolean { + return isRequired(arg.type) && arg.defaultValue == null; +} + /** Default literal value for an argument based on its type. Picks something * the user can immediately edit in place, instead of a `$var` reference * that forces a side-trip to the variables JSON. Common pagination names @@ -249,7 +259,7 @@ export const INTROSPECTION_QUERY = `query IntrospectionQuery { name description type { ${TYPE_REF} } args { - name description + name description defaultValue type { ${TYPE_REF} } } } diff --git a/src/renderer/src/lib/graphql-query-builder.ts b/src/renderer/src/lib/graphql-query-builder.ts new file mode 100644 index 0000000..5d7755e --- /dev/null +++ b/src/renderer/src/lib/graphql-query-builder.ts @@ -0,0 +1,266 @@ +// Copyright (c) 2024-2026 Testsmith.io +// SPDX-License-Identifier: MIT + +// AST-based GraphQL query building. Inserting a field from the schema explorer +// works on the parsed document (not string splicing), so the result is ALWAYS +// syntactically valid: fields land in the right operation/selection set, +// required arguments become typed variables (added to the operation signature +// and seeded into the variables JSON), and object/interface/union fields get a +// non-empty, valid selection set. Printing the AST guarantees valid syntax. + +import { parse, print, validate, Kind, type GraphQLSchema } from 'graphql'; +import type { + DocumentNode, OperationDefinitionNode, FieldNode, SelectionSetNode, + VariableDefinitionNode, ArgumentNode, TypeNode, SelectionNode, +} from 'graphql'; +import { + type GqlField, type GqlType, type GqlTypeRef, type ParsedSchema, + getBaseTypeName, getBaseKind, isLeafKind, argRequired, +} from './graphql-introspection'; + +export type OperationType = 'query' | 'mutation' | 'subscription'; + +const LEAF_LIMIT = 6; +const name = (value: string) => ({ kind: Kind.NAME as const, value }); + +// ─── GqlTypeRef -> AST TypeNode + JSON seed ─────────────────────────────────── + +function toTypeNode(ref: GqlTypeRef): TypeNode { + if (ref.kind === 'NON_NULL') return { kind: Kind.NON_NULL_TYPE, type: toTypeNode(ref.ofType!) as TypeNode & { kind: Kind.NAMED_TYPE | Kind.LIST_TYPE } }; + if (ref.kind === 'LIST') return { kind: Kind.LIST_TYPE, type: toTypeNode(ref.ofType!) }; + return { kind: Kind.NAMED_TYPE, name: name(ref.name ?? 'String') }; +} + +function isListRef(ref: GqlTypeRef): boolean { + return ref.kind === 'LIST' || (ref.kind === 'NON_NULL' && ref.ofType?.kind === 'LIST'); +} + +/** A placeholder value for a freshly-added variable, typed roughly right so the + * variables JSON is usable immediately (the user fills in real values). */ +function seedFor(ref: GqlTypeRef): unknown { + if (isListRef(ref)) return []; + switch (getBaseTypeName(ref)) { + case 'Int': case 'Long': case 'Float': case 'Double': return 0; + case 'Boolean': return false; + default: return ''; + } +} + +// ─── Argument / variable construction ───────────────────────────────────────── + +interface VarRegistry { + defs: Map // var name -> definition + seeds: Record +} + +// A unique variable name for (arg, field): prefer the bare arg name, but if it +// is already taken by a different type, qualify it with the field name. +function variableFor(field: GqlField, arg: GqlField['args'][number], reg: VarRegistry): string { + const typeStr = print(toTypeNode(arg.type)); + const existing = reg.defs.get(arg.name); + if (!existing) return arg.name; + if (print(existing.type) === typeStr) return arg.name; // same type -> share + return `${field.name}_${arg.name}`; +} + +// Well-known pagination arg names: when required (e.g. Lighthouse's +// `first: Int!`), we inline a sensible number so the query runs immediately +// instead of forcing the user to fill a variable. +const PAGE_SIZE = new Set(['first', 'last', 'limit', 'take', 'top', 'count', 'size', 'perpage', 'per_page']); +const PAGE_ZERO = new Set(['skip', 'offset']); + +// Only REQUIRED (non-null) arguments are added. Optional args (including +// optional pagination) are left out so a plain field inserts clean. A required +// pagination Int gets an inline literal; every other required arg becomes a +// typed variable (added to the signature + seeded into the variables JSON). +function buildArguments(field: GqlField, reg: VarRegistry): ArgumentNode[] { + const out: ArgumentNode[] = []; + for (const arg of field.args.filter(argRequired)) { + const base = getBaseTypeName(arg.type); + const lname = arg.name.toLowerCase(); + if ((base === 'Int' || base === 'Long') && (PAGE_SIZE.has(lname) || PAGE_ZERO.has(lname))) { + out.push({ kind: Kind.ARGUMENT, name: name(arg.name), value: { kind: Kind.INT, value: PAGE_ZERO.has(lname) ? '0' : '100' } }); + continue; + } + const varName = variableFor(field, arg, reg); + if (!reg.defs.has(varName)) { + reg.defs.set(varName, { kind: Kind.VARIABLE_DEFINITION, variable: { kind: Kind.VARIABLE, name: name(varName) }, type: toTypeNode(arg.type) }); + reg.seeds[varName] = seedFor(arg.type); + } + out.push({ kind: Kind.ARGUMENT, name: name(arg.name), value: { kind: Kind.VARIABLE, name: name(varName) } }); + } + return out; +} + +// ─── Selection sets ─────────────────────────────────────────────────────────── + +/** A valid, non-empty selection set for a composite type: its leaf (scalar/enum) + * fields, always at least __typename so unions (and fieldless types) stay valid. + * `all` selects every leaf field; otherwise the first LEAF_LIMIT. */ +function leafSelectionSet(baseTypeName: string, typeMap: Map, all: boolean): SelectionSetNode | undefined { + const type = typeMap.get(baseTypeName); + const selections: SelectionNode[] = []; + if (type?.fields?.length) { + for (const f of type.fields) { + if (isLeafKind(getBaseKind(f.type)) && f.args.every(a => !argRequired(a))) { + selections.push({ kind: Kind.FIELD, name: name(f.name) }); + if (!all && selections.length >= LEAF_LIMIT) break; + } + } + } + if (selections.length === 0) selections.push({ kind: Kind.FIELD, name: name('__typename') }); + return { kind: Kind.SELECTION_SET, selections }; +} + +/** Build a FieldNode for a field. `withSelection` adds a leaf selection set when + * the field's type is composite (only the deepest inserted field needs one; + * intermediate ancestors get their selection set from navigation). `all` picks + * every leaf field instead of the first few. */ +function buildFieldNode(field: GqlField, typeMap: Map, reg: VarRegistry, withSelection: boolean, all = false): FieldNode { + const args = buildArguments(field, reg); + const baseKind = getBaseKind(field.type); + const composite = baseKind === 'OBJECT' || baseKind === 'INTERFACE' || baseKind === 'UNION'; + return { + kind: Kind.FIELD, + name: name(field.name), + ...(args.length ? { arguments: args } : {}), + ...(withSelection && composite ? { selectionSet: leafSelectionSet(getBaseTypeName(field.type), typeMap, all) } : {}), + }; +} + +// ─── Document navigation ────────────────────────────────────────────────────── + +function findOrCreateOperation(doc: DocumentNode, opType: OperationType, opName?: string): { doc: DocumentNode; op: OperationDefinitionNode } { + const ops = doc.definitions.filter((d): d is OperationDefinitionNode => d.kind === Kind.OPERATION_DEFINITION); + const existing = ops.find(o => o.operation === opType); + if (existing) return { doc, op: existing }; + const op: OperationDefinitionNode = { + kind: Kind.OPERATION_DEFINITION, + operation: opType as OperationDefinitionNode['operation'], + ...(opName ? { name: name(opName) } : {}), + selectionSet: { kind: Kind.SELECTION_SET, selections: [] }, + }; + return { doc: { ...doc, definitions: [...doc.definitions, op] }, op }; +} + +// Resolve the chain of GqlFields for a path of field names, starting from the +// root type. Lets intermediate ancestors be created with their required args. +function resolveChain(rootTypeName: string, path: string[], typeMap: Map): GqlField[] { + const chain: GqlField[] = []; + let currentType = rootTypeName; + for (const fieldName of path) { + const f = typeMap.get(currentType)?.fields?.find(x => x.name === fieldName); + if (!f) break; + chain.push(f); + currentType = getBaseTypeName(f.type); + } + return chain; +} + +/** Add `sel` to a selection set if a field with the same name is not already + * present (mutating a copy). Returns the field node that now lives there. */ +function upsertField(set: { selections: SelectionNode[] }, fieldNode: FieldNode): FieldNode { + const existing = set.selections.find((s): s is FieldNode => s.kind === Kind.FIELD && s.name.value === fieldNode.name.value); + if (existing) return existing; + set.selections.push(fieldNode); + return fieldNode; +} + +// ─── Public API ─────────────────────────────────────────────────────────────── + +export interface InsertResult { query: string; variables: string } + +/** Insert `field` (found under `path` in the root `operationType` type) into the + * query, returning a printed, valid query and the merged variables JSON. */ +export function insertField( + currentQuery: string, + currentVariables: string, + schema: ParsedSchema, + operationType: OperationType, + path: string[], + field: GqlField, + operationName?: string, + opts: { allFields?: boolean } = {}, +): InsertResult { + const rootTypeName = + operationType === 'query' ? schema.queryType + : operationType === 'mutation' ? schema.mutationType + : schema.subscriptionType; + + // Parse the current query; on any problem start from a clean document so we + // never build on top of invalid syntax. + let doc: DocumentNode; + try { doc = currentQuery.trim() ? parse(currentQuery) : { kind: Kind.DOCUMENT, definitions: [] }; } + catch { doc = { kind: Kind.DOCUMENT, definitions: [] }; } + + const created = findOrCreateOperation(doc, operationType, operationName); + doc = created.doc; + + // Work on a deep-cloned operation so we can mutate selection sets freely. + const op = JSON.parse(JSON.stringify(created.op)) as OperationDefinitionNode; + const reg: VarRegistry = { defs: new Map((op.variableDefinitions ?? []).map(v => [v.variable.name.value, v])), seeds: {} }; + + // Navigate/create the ancestor chain, then insert the target field. + const chain = rootTypeName ? resolveChain(rootTypeName, path, schema.typeMap) : []; + let set = op.selectionSet as unknown as { selections: SelectionNode[] }; + for (let i = 0; i < path.length; i++) { + const ancestorField = chain[i]; + const node = ancestorField + ? buildFieldNode(ancestorField, schema.typeMap, reg, false) + : { kind: Kind.FIELD as const, name: name(path[i]) }; + const here = upsertField(set, node); + if (!here.selectionSet) (here as { selectionSet?: SelectionSetNode }).selectionSet = { kind: Kind.SELECTION_SET, selections: [] }; + set = here.selectionSet as unknown as { selections: SelectionNode[] }; + } + upsertField(set, buildFieldNode(field, schema.typeMap, reg, true, opts.allFields)); + + (op as { variableDefinitions?: VariableDefinitionNode[] }).variableDefinitions = [...reg.defs.values()]; + if (operationName && !op.name) (op as { name?: ReturnType }).name = name(operationName); + + // `doc` already contains created.op (findOrCreateOperation appends it); swap + // in the mutated clone. + const definitions = doc.definitions.map(d => (d === created.op ? op : d)); + return { + query: print({ kind: Kind.DOCUMENT, definitions }), + variables: mergeVariables(currentVariables, reg.seeds), + }; +} + +/** Merge new variable seeds into the variables JSON without clobbering values + * the user already set. */ +export function mergeVariables(currentJson: string, seeds: Record): string { + if (Object.keys(seeds).length === 0) return currentJson; + let obj: Record = {}; + try { obj = currentJson.trim() ? JSON.parse(currentJson) : {}; } catch { obj = {}; } + const merged = { ...seeds, ...obj }; // existing values win + return JSON.stringify(merged, null, 2); +} + +// ─── Validation ─────────────────────────────────────────────────────────────── + +export interface QueryProblem { message: string; severity: 'error' | 'warning' } + +/** Validate the query against the schema, plus a check that every used variable + * is present in the variables JSON. Empty array means all good. */ +export function validateQuery(schema: GraphQLSchema | null, query: string, variablesJson: string): QueryProblem[] { + if (!query.trim()) return []; + let doc: DocumentNode; + try { doc = parse(query); } + catch (e) { return [{ message: e instanceof Error ? e.message : String(e), severity: 'error' }]; } + + const problems: QueryProblem[] = []; + if (schema) { + for (const err of validate(schema, doc)) problems.push({ message: err.message, severity: 'error' }); + } + + // Variables referenced but not provided (warning, since some may be intended + // to come from {{env}} substitution at send-time). + let provided: Record = {}; + try { provided = variablesJson.trim() ? JSON.parse(variablesJson) : {}; } catch { /* invalid JSON handled elsewhere */ } + const used = new Set(); + for (const m of query.matchAll(/\$([A-Za-z_][A-Za-z0-9_]*)/g)) used.add(m[1]); + for (const v of used) { + if (!(v in provided)) problems.push({ message: `Variable "$${v}" is not set in Variables`, severity: 'warning' }); + } + return problems; +} diff --git a/src/shared/types/http.ts b/src/shared/types/http.ts index 3c31061..9c8ea6d 100644 --- a/src/shared/types/http.ts +++ b/src/shared/types/http.ts @@ -79,6 +79,11 @@ export interface SoapBody { serviceName?: string portName?: string operationName?: string + /** SOAP version + binding of the selected operation. A WSDL can expose the + * same operation name in both a 1.1 and a 1.2 binding, so these disambiguate + * which one is selected (operationName alone is not unique). */ + soapVersion?: '1.1' | '1.2' + binding?: string envelope: string // the XML envelope (hand-edited or template-generated) soapAction?: string } diff --git a/src/tests/graphql-query-builder.test.ts b/src/tests/graphql-query-builder.test.ts new file mode 100644 index 0000000..4ebd469 --- /dev/null +++ b/src/tests/graphql-query-builder.test.ts @@ -0,0 +1,137 @@ +// Copyright (c) 2024-2026 Testsmith.io +// SPDX-License-Identifier: MIT + +import { describe, it, expect } from 'vitest'; +import { parse } from 'graphql'; +import { insertField, mergeVariables } from '../renderer/src/lib/graphql-query-builder'; +import type { ParsedSchema, GqlType, GqlField, GqlTypeRef } from '../renderer/src/lib/graphql-introspection'; + +// ── tiny schema builders ── +const scalar = (name: string): GqlTypeRef => ({ kind: 'SCALAR', name, ofType: null }); +const obj = (name: string): GqlTypeRef => ({ kind: 'OBJECT', name, ofType: null }); +const union = (name: string): GqlTypeRef => ({ kind: 'UNION', name, ofType: null }); +const nn = (ref: GqlTypeRef): GqlTypeRef => ({ kind: 'NON_NULL', name: null, ofType: ref }); +const list = (ref: GqlTypeRef): GqlTypeRef => ({ kind: 'LIST', name: null, ofType: ref }); +const field = (name: string, type: GqlTypeRef, args: GqlField['args'] = []): GqlField => ({ name, type, args }); + +const typeMap = new Map([ + ['Query', { name: 'Query', kind: 'OBJECT', fields: [ + field('user', obj('User'), [{ name: 'id', type: nn(scalar('ID')) }]), + field('products', list(obj('Product')), [{ name: 'first', type: scalar('Int') }]), + field('search', union('Result')), + ] }], + ['User', { name: 'User', kind: 'OBJECT', fields: [ + field('id', nn(scalar('ID'))), field('name', scalar('String')), field('address', obj('Address')), + ] }], + ['Address', { name: 'Address', kind: 'OBJECT', fields: [field('city', scalar('String'))] }], + ['Product', { name: 'Product', kind: 'OBJECT', fields: [field('id', nn(scalar('ID'))), field('price', scalar('Float'))] }], + ['Result', { name: 'Result', kind: 'UNION', fields: null }], + // brands(page: Int) -> BrandConn { data: [Brand { name }] } : optional-arg chain. + ['BrandConn', { name: 'BrandConn', kind: 'OBJECT', fields: [field('data', list(obj('Brand')))] }], + ['Brand', { name: 'Brand', kind: 'OBJECT', fields: [ + 'name', 'code', 'slug', 'active', 'createdAt', 'updatedAt', 'description', 'sku', + ].map(n => field(n, scalar('String'))) }], +]); +typeMap.get('Query')!.fields!.push(field('brands', obj('BrandConn'), [{ name: 'page', type: scalar('Int') }])); +// Lighthouse-style mandatory pagination: first is Int! (required, no default). +typeMap.get('Query')!.fields!.push(field('catalog', obj('BrandConn'), [{ name: 'first', type: nn(scalar('Int')) }])); +// Non-null arg WITH a default (first: Int! = 10): optional to provide. +typeMap.get('Query')!.fields!.push(field('feed', obj('BrandConn'), [{ name: 'first', type: nn(scalar('Int')), defaultValue: '10' }])); + +const schema: ParsedSchema = { queryType: 'Query', mutationType: null, subscriptionType: null, typeMap }; + +function valid(query: string) { expect(() => parse(query)).not.toThrow(); } + +describe('insertField', () => { + it('inserts a root field with a required arg as a typed variable, producing valid syntax', () => { + const { query, variables } = insertField('', '', schema, 'query', [], typeMap.get('Query')!.fields![0]); + valid(query); + expect(query).toMatch(/user\(id: \$id\)/); + expect(query).toMatch(/query \(\$id: ID!\)/); // variable added to the signature + expect(query).toMatch(/\bid\b/); // leaf selection + expect(JSON.parse(variables)).toHaveProperty('id', ''); + }); + + it('leaves optional args out entirely (clean field, no args or variables)', () => { + const { query, variables } = insertField('', '', schema, 'query', [], typeMap.get('Query')!.fields![1]); + valid(query); + expect(query).toMatch(/products \{/); // no (first: ...) argument list + expect(query).not.toContain('('); + expect(variables).toBe(''); + }); + + it('gives a union field a valid selection (__typename)', () => { + const { query } = insertField('', '', schema, 'query', [], typeMap.get('Query')!.fields![2]); + valid(query); + expect(query).toMatch(/search \{[^}]*__typename/s); + }); + + it('inserts a nested field under a path, creating the ancestor if missing', () => { + const address = typeMap.get('User')!.fields!.find(f => f.name === 'address')!; + const { query } = insertField('', '', schema, 'query', ['user'], address); + valid(query); + expect(query).toMatch(/user\(id: \$id\)/); // ancestor created with its required arg + expect(query).toMatch(/address \{[\s\S]*city/); // nested field with a leaf + }); + + it('builds a clean nested query by inserting the leaf, with no args or variables', () => { + const nameField = typeMap.get('Brand')!.fields![0]; + const { query, variables } = insertField('', '', schema, 'query', ['brands', 'data'], nameField); + valid(query); + expect(query).not.toContain('('); // no page / args anywhere + expect(query).not.toContain('__typename'); // ancestors get real selections, not a placeholder + expect(variables).toBe(''); // no variables + expect(query.replace(/\s+/g, ' ').trim()).toBe('{ brands { data { name } } }'); + }); + + it('all-fields insert selects every leaf field of the entity, no args or variables', () => { + const dataField = typeMap.get('BrandConn')!.fields![0]; // data: [Brand] + const all = insertField('', '', schema, 'query', ['brands'], dataField, undefined, { allFields: true }); + valid(all.query); + for (const f of typeMap.get('Brand')!.fields!) expect(all.query).toContain(f.name); // every Brand field + expect(all.query).not.toContain('('); + expect(all.variables).toBe(''); + + // The default insert caps the selection, so it omits the later fields. + const some = insertField('', '', schema, 'query', ['brands'], dataField); + expect(some.query).not.toContain('sku'); + }); + + it('inlines a required pagination arg as a number, no variable', () => { + const dataField = typeMap.get('BrandConn')!.fields![0]; + const { query, variables } = insertField('', '', schema, 'query', ['catalog'], dataField, undefined, { allFields: true }); + valid(query); + expect(query).toMatch(/catalog\(first: 100\)/); + expect(query).not.toContain('$first'); + expect(variables).toBe(''); + }); + + it('treats a non-null arg with a default value as optional (omits it)', () => { + const dataField = typeMap.get('BrandConn')!.fields![0]; + const { query, variables } = insertField('', '', schema, 'query', ['feed'], dataField, undefined, { allFields: true }); + valid(query); + expect(query).toMatch(/feed \{/); // no (first: ...) despite Int! + expect(query).not.toContain('first'); + expect(variables).toBe(''); + }); + + it('merges into an existing query without duplicating and stays valid', () => { + const first = insertField('', '', schema, 'query', [], typeMap.get('Query')!.fields![0]); + const second = insertField(first.query, first.variables, schema, 'query', [], typeMap.get('Query')!.fields![1]); + valid(second.query); + expect(second.query).toMatch(/user\(id: \$id\)/); + expect(second.query).toMatch(/products \{/); + // inserting user again does not duplicate it + const third = insertField(second.query, second.variables, schema, 'query', [], typeMap.get('Query')!.fields![0]); + expect(third.query.match(/user\(/g)).toHaveLength(1); + }); +}); + +describe('mergeVariables', () => { + it('adds seeds without clobbering existing values', () => { + expect(JSON.parse(mergeVariables('{"id":"abc"}', { id: '', page: 0 }))).toEqual({ id: 'abc', page: 0 }); + }); + it('returns input unchanged when there are no seeds', () => { + expect(mergeVariables('{"a":1}', {})).toBe('{"a":1}'); + }); +});