@apiquest/plugin-graphql 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,125 @@
1
+ # @apiquest/plugin-graphql
2
+
3
+ GraphQL protocol plugin for ApiQuest. Provides support for GraphQL queries, mutations, and subscriptions with variable support.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install -g @apiquest/plugin-graphql
9
+ ```
10
+
11
+ ## Features
12
+
13
+ - GraphQL queries and mutations
14
+ - Variable support
15
+ - Operation name specification (for multi-operation documents)
16
+ - Custom HTTP headers
17
+ - Fragment support
18
+ - Authentication integration (via `@apiquest/plugin-auth`)
19
+
20
+ ## Usage
21
+
22
+ Set the collection protocol to `graphql`:
23
+
24
+ ```json
25
+ {
26
+ "$schema": "https://apiquest.net/schemas/collection-v1.0.json",
27
+ "protocol": "graphql",
28
+ "items": [
29
+ {
30
+ "type": "request",
31
+ "id": "get-user",
32
+ "name": "Get User by ID",
33
+ "data": {
34
+ "url": "https://api.example.com/graphql",
35
+ "query": "query GetUser($id: ID!) {\n user(id: $id) {\n id\n name\n email\n }\n}",
36
+ "variables": {
37
+ "id": "{{userId}}"
38
+ }
39
+ }
40
+ }
41
+ ]
42
+ }
43
+ ```
44
+
45
+ ### Mutation Example
46
+
47
+ ```json
48
+ {
49
+ "type": "request",
50
+ "id": "create-user",
51
+ "name": "Create User",
52
+ "data": {
53
+ "url": "https://api.example.com/graphql",
54
+ "mutation": "mutation CreateUser($input: UserInput!) {\n createUser(input: $input) {\n id\n name\n email\n }\n}",
55
+ "variables": {
56
+ "input": {
57
+ "name": "John Doe",
58
+ "email": "john@example.com"
59
+ }
60
+ }
61
+ }
62
+ }
63
+ ```
64
+
65
+ ### With Custom Headers
66
+
67
+ ```json
68
+ {
69
+ "data": {
70
+ "url": "https://api.example.com/graphql",
71
+ "query": "{ users { id name } }",
72
+ "headers": {
73
+ "x-api-version": "2024-01-01",
74
+ "x-request-id": "{{$guid}}"
75
+ }
76
+ }
77
+ }
78
+ ```
79
+
80
+ ### Multi-Operation Documents
81
+
82
+ ```json
83
+ {
84
+ "data": {
85
+ "url": "https://api.example.com/graphql",
86
+ "query": "query GetUser { user { id } }\nquery GetPosts { posts { id } }",
87
+ "operationName": "GetUser"
88
+ }
89
+ }
90
+ ```
91
+
92
+ ## Response Handling
93
+
94
+ Access GraphQL response data in post-request scripts:
95
+
96
+ ```javascript
97
+ quest.test('Query successful', () => {
98
+ expect(quest.response.status).to.equal(200);
99
+ });
100
+
101
+ quest.test('No GraphQL errors', () => {
102
+ const body = quest.response.json();
103
+ expect(body.errors).to.be.undefined;
104
+ });
105
+
106
+ quest.test('User data returned', () => {
107
+ const body = quest.response.json();
108
+ expect(body.data.user).to.be.an('object');
109
+ expect(body.data.user.id).to.be.a('string');
110
+ });
111
+ ```
112
+
113
+ ## Compatibility
114
+
115
+ - **Authentication:** Works with `@apiquest/plugin-auth` for Bearer, Basic, OAuth2, API Key
116
+ - **Node.js:** Requires Node.js 20+
117
+
118
+ ## Documentation
119
+
120
+ - [Fracture Documentation](https://apiquest.net/docs/fracture)
121
+ - [Schema Reference](https://apiquest.net/schemas/collection-v1.0.json)
122
+
123
+ ## License
124
+
125
+ Dual-licensed under AGPL-3.0-or-later and commercial license. See LICENSE.txt for details.
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@apiquest/plugin-graphql",
3
+ "version": "1.0.2",
4
+ "description": "GraphQL protocol plugin for ApiQuest",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "scripts": {
9
+ "build": "rollup -c && tsc --emitDeclarationOnly",
10
+ "dev": "rollup -c --watch",
11
+ "test": "vitest"
12
+ },
13
+ "keywords": [
14
+ "apiquest",
15
+ "graphql",
16
+ "plugin"
17
+ ],
18
+ "author": "",
19
+ "license": "AGPL-3.0-or-later",
20
+ "dependencies": {
21
+ "@apiquest/types": "workspace:*",
22
+ "got": "^14.6.6"
23
+ },
24
+ "devDependencies": {
25
+ "@apiquest/types": "workspace:*",
26
+ "@rollup/plugin-commonjs": "^25.0.0",
27
+ "@rollup/plugin-node-resolve": "^15.0.0",
28
+ "@rollup/plugin-typescript": "^11.0.0",
29
+ "@types/node": "^20.10.6",
30
+ "rollup": "^4.0.0",
31
+ "typescript": "^5.3.3",
32
+ "vitest": "^4.0.18"
33
+ },
34
+ "apiquest": {
35
+ "type": "protocol",
36
+ "runtime": [
37
+ "fracture"
38
+ ],
39
+ "capabilities": {
40
+ "provides": {
41
+ "protocols": [
42
+ "graphql"
43
+ ]
44
+ },
45
+ "supports": {
46
+ "authTypes": [
47
+ "bearer",
48
+ "basic",
49
+ "oauth2",
50
+ "apikey"
51
+ ],
52
+ "strictAuthList": false
53
+ }
54
+ }
55
+ }
56
+ }
@@ -0,0 +1,31 @@
1
+ import typescript from '@rollup/plugin-typescript';
2
+ import resolve from '@rollup/plugin-node-resolve';
3
+ import commonjs from '@rollup/plugin-commonjs';
4
+
5
+ export default {
6
+ input: 'src/index.ts',
7
+ output: {
8
+ file: 'dist/index.js',
9
+ format: 'esm',
10
+ sourcemap: true,
11
+ },
12
+ external: [
13
+ // Externalize peer dependencies
14
+ '@apiquest/fracture',
15
+ ],
16
+ plugins: [
17
+ // Resolve node modules
18
+ resolve({
19
+ preferBuiltins: true, // Prefer Node.js built-in modules
20
+ exportConditions: ['node', 'import', 'default'],
21
+ }),
22
+ // Convert CommonJS to ESM (for any CJS dependencies)
23
+ commonjs(),
24
+ // Compile TypeScript
25
+ typescript({
26
+ tsconfig: './tsconfig.json',
27
+ sourceMap: true,
28
+ declaration: false, // We'll use tsc for declarations
29
+ }),
30
+ ],
31
+ };
package/src/index.ts ADDED
@@ -0,0 +1,291 @@
1
+ import got, { OptionsOfTextResponseBody, Response, RequestError } from 'got';
2
+ import type { IProtocolPlugin, Request, ExecutionContext, ProtocolResponse, ValidationResult, ValidationError, RuntimeOptions, ILogger } from '@apiquest/types';
3
+
4
+ // Helper functions for string validation
5
+ function isNullOrEmpty(value: string | null | undefined): boolean {
6
+ return value === null || value === undefined || value === '';
7
+ }
8
+
9
+ function isNullOrWhitespace(value: string | null | undefined): boolean {
10
+ return value === null || value === undefined || value.trim() === '';
11
+ }
12
+
13
+ export const graphqlPlugin: IProtocolPlugin = {
14
+ name: 'GraphQL',
15
+ version: '1.0.0',
16
+ description: 'GraphQL query and mutation support',
17
+ protocols: ['graphql'],
18
+ supportedAuthTypes: ['bearer', 'basic', 'apikey', 'oauth2'],
19
+ strictAuthList: false,
20
+ dataSchema: {
21
+ type: 'object',
22
+ required: ['url'],
23
+ properties: {
24
+ url: {
25
+ type: 'string',
26
+ description: 'GraphQL endpoint URL'
27
+ },
28
+ query: {
29
+ type: 'string',
30
+ description: 'GraphQL query'
31
+ },
32
+ mutation: {
33
+ type: 'string',
34
+ description: 'GraphQL mutation'
35
+ },
36
+ variables: {
37
+ type: 'object',
38
+ description: 'GraphQL variables'
39
+ },
40
+ operationName: {
41
+ type: 'string',
42
+ description: 'Operation name (for multi-operation documents)'
43
+ },
44
+ headers: {
45
+ type: 'object',
46
+ description: 'Custom HTTP headers',
47
+ additionalProperties: { type: 'string' }
48
+ }
49
+ }
50
+ },
51
+
52
+ // Options schema for runtime configuration
53
+ optionsSchema: {
54
+ timeout: {
55
+ type: 'number',
56
+ default: 30000,
57
+ description: 'Request timeout in milliseconds'
58
+ },
59
+ validateCertificates: {
60
+ type: 'boolean',
61
+ default: true,
62
+ description: 'Validate SSL/TLS certificates'
63
+ }
64
+ },
65
+
66
+ validate(request: Request, options: RuntimeOptions): ValidationResult {
67
+ const errors: ValidationError[] = [];
68
+
69
+ // Check URL
70
+ if (typeof request.data.url !== 'string' || isNullOrWhitespace(request.data.url)) {
71
+ errors.push({
72
+ message: 'GraphQL endpoint URL is required',
73
+ location: '',
74
+ source: 'protocol'
75
+ });
76
+ }
77
+
78
+ // Check that either query or mutation is present
79
+ const hasQuery = typeof request.data.query === 'string' && !isNullOrEmpty(request.data.query);
80
+ const hasMutation = typeof request.data.mutation === 'string' && !isNullOrEmpty(request.data.mutation);
81
+
82
+ if (!hasQuery && !hasMutation) {
83
+ errors.push({
84
+ message: 'Either query or mutation is required',
85
+ location: '',
86
+ source: 'protocol'
87
+ });
88
+ }
89
+
90
+ if (hasQuery && hasMutation) {
91
+ errors.push({
92
+ message: 'Cannot have both query and mutation - use one or the other',
93
+ location: '',
94
+ source: 'protocol'
95
+ });
96
+ }
97
+
98
+ if (errors.length > 0) {
99
+ return {
100
+ valid: false,
101
+ errors
102
+ };
103
+ }
104
+
105
+ return { valid: true };
106
+ },
107
+
108
+ async execute(
109
+ request: Request,
110
+ context: ExecutionContext,
111
+ options: RuntimeOptions,
112
+ emitEvent?: (eventName: string, eventData: unknown) => Promise<void>,
113
+ logger?: ILogger
114
+ ): Promise<ProtocolResponse> {
115
+ const startTime = Date.now();
116
+ const url = String(request.data.url ?? '');
117
+
118
+ try {
119
+ if (isNullOrWhitespace(url)) {
120
+ logger?.error('GraphQL request missing URL');
121
+ throw new Error('GraphQL endpoint URL is required');
122
+ }
123
+
124
+ // Request configuration
125
+ const operation = request.data.query ?? request.data.mutation;
126
+
127
+ const graphqlBody: {
128
+ query: string;
129
+ variables?: Record<string, unknown>;
130
+ operationName?: string;
131
+ } = {
132
+ query: String(operation),
133
+ };
134
+
135
+ if (request.data.variables !== undefined && request.data.variables !== null) {
136
+ graphqlBody.variables = request.data.variables as Record<string, unknown>;
137
+ }
138
+
139
+ if (request.data.operationName !== undefined && request.data.operationName !== null) {
140
+ graphqlBody.operationName = String(request.data.operationName);
141
+ }
142
+
143
+ // Headers
144
+ const headers: Record<string, string> = {
145
+ 'Content-Type': 'application/json',
146
+ };
147
+
148
+ if (typeof request.data.headers === 'object' && request.data.headers !== null) {
149
+ Object.entries(request.data.headers as Record<string, unknown>).forEach(([key, value]) => {
150
+ headers[key] = String(value);
151
+ });
152
+ }
153
+
154
+ const graphqlOptions: Record<string, unknown> = (options.plugins?.graphql as Record<string, unknown> | null | undefined) ?? {};
155
+ const graphqlTimeout = typeof graphqlOptions.timeout === 'number' ? graphqlOptions.timeout : null;
156
+ const timeout = options.timeout?.request ?? graphqlTimeout ?? 60000;
157
+ const graphqlValidateCerts = typeof graphqlOptions.validateCertificates === 'boolean' ? graphqlOptions.validateCertificates : null;
158
+ const validateCerts = options.ssl?.validateCertificates ?? graphqlValidateCerts ?? true;
159
+
160
+ logger?.debug('GraphQL request options resolved', { timeout, validateCerts });
161
+
162
+ // Cookie handling
163
+ const cookieHeader = context.cookieJar.getCookieHeader(url);
164
+ if (cookieHeader !== null) {
165
+ headers['Cookie'] = cookieHeader;
166
+ logger?.trace('Cookie header applied', { url });
167
+ }
168
+
169
+ const gotOptions: OptionsOfTextResponseBody = {
170
+ method: 'POST',
171
+ headers: { ...headers },
172
+ json: graphqlBody,
173
+ throwHttpErrors: false,
174
+ timeout: { request: timeout },
175
+ followRedirect: true,
176
+ https: {
177
+ rejectUnauthorized: validateCerts
178
+ }
179
+ };
180
+
181
+ // Dispatch
182
+ logger?.debug('GraphQL request dispatch', { url });
183
+ const response: Response = await got(url, gotOptions);
184
+ const duration = Date.now() - startTime;
185
+
186
+ // Response normalization
187
+ const normalizedHeaders: Record<string, string | string[]> = {};
188
+ if (typeof response.headers === 'object' && response.headers !== null) {
189
+ Object.entries(response.headers).forEach(([key, value]) => {
190
+ if (Array.isArray(value)) {
191
+ normalizedHeaders[key.toLowerCase()] = value.map(item => String(item));
192
+ } else if (value !== undefined && value !== null) {
193
+ normalizedHeaders[key.toLowerCase()] = String(value);
194
+ }
195
+ });
196
+ }
197
+
198
+ if (normalizedHeaders['set-cookie'] !== undefined) {
199
+ context.cookieJar.store(normalizedHeaders['set-cookie'], url);
200
+ logger?.trace('Cookies stored from response', { url });
201
+ }
202
+
203
+ let errorMsg: string | undefined = undefined;
204
+ try {
205
+ const responseData = JSON.parse(String(response.body)) as { errors?: Array<{ message: string }> };
206
+ if (responseData?.errors !== undefined && responseData.errors !== null && responseData.errors.length > 0) {
207
+ errorMsg = `GraphQL errors: ${responseData.errors.map((e: { message: string }) => e.message).join(', ')}`;
208
+ }
209
+ } catch (parseError) {
210
+ logger?.trace('GraphQL response body not JSON');
211
+ }
212
+
213
+ logger?.debug('GraphQL response received', { status: response.statusCode, duration });
214
+
215
+ return {
216
+ status: response.statusCode,
217
+ statusText: (response.statusMessage !== null && response.statusMessage !== undefined && response.statusMessage.length > 0) ? response.statusMessage : '',
218
+ headers: normalizedHeaders,
219
+ body: String(response.body),
220
+ duration,
221
+ error: errorMsg
222
+ };
223
+ } catch (err) {
224
+ const duration = Date.now() - startTime;
225
+ const error = err as RequestError;
226
+
227
+ if (error instanceof RequestError) {
228
+ if (error.response !== undefined) {
229
+ const normalizedHeaders: Record<string, string | string[]> = {};
230
+ if (typeof error.response.headers === 'object' && error.response.headers !== null) {
231
+ Object.entries(error.response.headers).forEach(([key, value]) => {
232
+ if (Array.isArray(value)) {
233
+ normalizedHeaders[key.toLowerCase()] = value.map(item => String(item));
234
+ } else if (value !== undefined && value !== null) {
235
+ normalizedHeaders[key.toLowerCase()] = String(value);
236
+ }
237
+ });
238
+ }
239
+
240
+ if (normalizedHeaders['set-cookie'] !== undefined) {
241
+ context.cookieJar.store(normalizedHeaders['set-cookie'], url);
242
+ logger?.trace('Cookies stored from error response', { url });
243
+ }
244
+
245
+ let errorResponseMsg: string | undefined = undefined;
246
+ try {
247
+ const responseData = JSON.parse(String(error.response.body)) as { errors?: Array<{ message: string }> };
248
+ if (responseData?.errors !== undefined && responseData.errors !== null && responseData.errors.length > 0) {
249
+ errorResponseMsg = `GraphQL errors: ${responseData.errors.map((e: { message: string }) => e.message).join(', ')}`;
250
+ }
251
+ } catch (parseError) {
252
+ logger?.trace('GraphQL error response body not JSON');
253
+ }
254
+
255
+ logger?.debug('GraphQL error response received', { status: error.response.statusCode, duration });
256
+
257
+ return {
258
+ status: error.response.statusCode,
259
+ statusText: (error.response.statusMessage !== null && error.response.statusMessage !== undefined && error.response.statusMessage.length > 0) ? error.response.statusMessage : '',
260
+ headers: normalizedHeaders,
261
+ body: String(error.response.body),
262
+ duration,
263
+ error: errorResponseMsg
264
+ };
265
+ } else {
266
+ logger?.warn('GraphQL network error', { message: error.message, duration });
267
+ return {
268
+ status: 0,
269
+ statusText: 'Network Error',
270
+ headers: {},
271
+ body: '',
272
+ duration,
273
+ error: !isNullOrEmpty(error.message) ? error.message : 'Network request failed'
274
+ };
275
+ }
276
+ }
277
+
278
+ logger?.error('GraphQL unexpected error', { error: err instanceof Error ? err.message : String(err), duration });
279
+ return {
280
+ status: 0,
281
+ statusText: 'Error',
282
+ headers: {},
283
+ body: '',
284
+ duration,
285
+ error: err instanceof Error ? err.message : String(err)
286
+ };
287
+ }
288
+ },
289
+ };
290
+
291
+ export default graphqlPlugin;
package/tsconfig.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ES2022",
5
+ "lib": ["ES2022"],
6
+ "moduleResolution": "bundler",
7
+ "outDir": "./dist",
8
+ "declaration": true,
9
+ "declarationMap": true,
10
+ "sourceMap": true,
11
+ "strict": true,
12
+ "esModuleInterop": true,
13
+ "skipLibCheck": true,
14
+ "forceConsistentCasingInFileNames": true,
15
+ "resolveJsonModule": true,
16
+ "allowSyntheticDefaultImports": true
17
+ },
18
+ "include": ["src/**/*"],
19
+ "exclude": ["node_modules", "dist", "tests"]
20
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "include": ["tests/**/*"],
4
+ "exclude": []
5
+ }
@@ -0,0 +1,12 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: 'node',
7
+ coverage: {
8
+ provider: 'v8',
9
+ reporter: ['text', 'json', 'html'],
10
+ },
11
+ },
12
+ });