Skip to content
Open
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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -838,6 +838,14 @@ You can also execute GraphQL from a file using the `-f` flag:

pos-cli exec graphql staging -f path/to/query.graphql

Pass GraphQL variables as a JSON object with the `-p, --params` flag:

pos-cli exec graphql staging 'query q($id: ID) { record(id: $id) { id } }' --params '{"id":"42"}'

The `--params` flag can be combined with `-f` to supply variables for a query read from a file:

pos-cli exec graphql staging -f path/to/query.graphql --params '{"id":"42"}'

**Note:** When executing on production environments (environment name contains "prod" or "production"), you will be prompted for confirmation before execution.

### Running Tests
Expand Down
43 changes: 13 additions & 30 deletions bin/pos-cli-exec-graphql.js
Original file line number Diff line number Diff line change
@@ -1,46 +1,29 @@
#!/usr/bin/env node

import fs from 'fs';
import { program } from '../lib/program.js';
import Gateway from '../lib/proxy.js';
import { fetchSettings } from '../lib/settings.js';
import logger from '../lib/logger.js';
import { isProductionEnvironment, confirmProductionExecution } from '../lib/productionEnvironment.js';
import { execGraphql } from '../lib/exec/graphql.js';

program
.name('pos-cli exec graphql')
.argument('<environment>', 'name of environment. Example: staging')
.argument('[graphql]', 'graphql query to execute as string')
.option('-f, --file <path>', 'path to graphql file to execute')
.option('-p, --params <json>', 'GraphQL variables as a JSON object. Example: \'{"id":"42"}\'')
.action(async (environment, graphql, options) => {
let query = graphql;

if (options.file) {
if (!fs.existsSync(options.file)) {
await logger.Error(`File not found: ${options.file}`);
process.exit(1);
}
query = fs.readFileSync(options.file, 'utf8');
}

if (!query) {
await logger.Error("error: missing required argument 'graphql'");
process.exit(1);
}

const authData = await fetchSettings(environment, program);
const gateway = new Gateway(authData);

if (isProductionEnvironment(environment)) {
const confirmed = await confirmProductionExecution(environment);
if (!confirmed) {
try {
const { response, cancelled } = await execGraphql({
environment,
query: graphql,
file: options.file,
params: options.params,
program,
});

if (cancelled) {
logger.Info('Execution cancelled.');
process.exit(0);
}
}

try {
const response = await gateway.graph({ query });

if (response.errors) {
await logger.Error(`GraphQL execution error: ${JSON.stringify(response.errors, null, 2)}`);
Expand All @@ -56,4 +39,4 @@ program
}
});

program.parse(process.argv);
program.parse(process.argv);
41 changes: 11 additions & 30 deletions bin/pos-cli-exec-liquid.js
Original file line number Diff line number Diff line change
@@ -1,46 +1,27 @@
#!/usr/bin/env node

import fs from 'fs';
import { program } from '../lib/program.js';
import Gateway from '../lib/proxy.js';
import { fetchSettings } from '../lib/settings.js';
import logger from '../lib/logger.js';
import { isProductionEnvironment, confirmProductionExecution } from '../lib/productionEnvironment.js';
import { execLiquid } from '../lib/exec/liquid.js';

program
.name('pos-cli exec liquid')
.argument('<environment>', 'name of environment. Example: staging')
.argument('[code]', 'liquid code to execute as string')
.option('-f, --file <path>', 'path to liquid file to execute')
.action(async (environment, code, options) => {
let liquidCode = code;

if (options.file) {
if (!fs.existsSync(options.file)) {
await logger.Error(`File not found: ${options.file}`);
process.exit(1);
}
liquidCode = fs.readFileSync(options.file, 'utf8');
}

if (!liquidCode) {
await logger.Error("error: missing required argument 'code'");
process.exit(1);
}

const authData = await fetchSettings(environment, program);
const gateway = new Gateway(authData);

if (isProductionEnvironment(environment)) {
const confirmed = await confirmProductionExecution(environment);
if (!confirmed) {
try {
const { response, cancelled } = await execLiquid({
environment,
code,
file: options.file,
program,
});

if (cancelled) {
logger.Info('Execution cancelled.');
process.exit(0);
}
}

try {
const response = await gateway.liquid({ content: liquidCode });

if (response.error) {
await logger.Error(`Liquid execution error: ${response.error}`);
Expand All @@ -56,4 +37,4 @@ program
}
});

program.parse(process.argv);
program.parse(process.argv);
37 changes: 37 additions & 0 deletions lib/exec/graphql.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { runExec } from './run.js';

// Parse the --params option into a GraphQL variables object.
// Accepts a JSON object string; returns {} when nothing was provided.
export function parseParams(raw) {
if (raw === undefined || raw === null || raw === '') {
return {};
}

let parsed;
try {
parsed = JSON.parse(raw);
} catch (e) {
throw new Error(`Invalid --params value: expected a JSON object of variables (${e.message})`);
}

if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error('Invalid --params value: expected a JSON object of variables');
}

return parsed;
}

// Core logic for `pos-cli exec graphql`.
export async function execGraphql({ environment, query, file, params, program, deps = {} }) {
const variables = parseParams(params);

return runExec({
environment,
source: query,
file,
program,
missingArgName: 'graphql',
execute: (gateway, resolvedQuery) => gateway.graph({ query: resolvedQuery, variables }),
deps,
});
}
14 changes: 14 additions & 0 deletions lib/exec/liquid.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { runExec } from './run.js';

// Core logic for `pos-cli exec liquid`.
export async function execLiquid({ environment, code, file, program, deps = {} }) {
return runExec({
environment,
source: code,
file,
program,
missingArgName: 'code',
execute: (gateway, content) => gateway.liquid({ content }),
deps,
});
}
50 changes: 50 additions & 0 deletions lib/exec/run.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import fs from 'fs';
import Gateway from '../proxy.js';
import { fetchSettings } from '../settings.js';
import { isProductionEnvironment, confirmProductionExecution } from '../productionEnvironment.js';

// Resolve the source to execute from either an inline argument or a --file path.
export function resolveSource({ source, file, fileReader = fs }) {
if (file) {
if (!fileReader.existsSync(file)) {
throw new Error(`File not found: ${file}`);
}
return fileReader.readFileSync(file, 'utf8');
}

return source;
}

// Shared core for the `pos-cli exec *` commands, decoupled from the CLI shell so
// it can be unit tested. It resolves the source, sets up the gateway, enforces
// production confirmation, then delegates the actual API call to `execute`.
//
// Dependencies are injectable via `deps` for testing; the bin passes none and
// gets the real implementations.
export async function runExec({ environment, source, file, program, missingArgName, execute, deps = {} }) {
const {
GatewayCtor = Gateway,
fetchSettingsFn = fetchSettings,
fileReader = fs,
isProductionEnvironmentFn = isProductionEnvironment,
confirmProductionExecutionFn = confirmProductionExecution,
} = deps;

const resolved = resolveSource({ source, file, fileReader });
if (!resolved) {
throw new Error(`missing required argument '${missingArgName}'`);
}

const authData = await fetchSettingsFn(environment, program);
const gateway = new GatewayCtor(authData);

if (isProductionEnvironmentFn(environment)) {
const confirmed = await confirmProductionExecutionFn(environment);
if (!confirmed) {
return { cancelled: true };
}
}

const response = await execute(gateway, resolved);
return { response };
}
104 changes: 104 additions & 0 deletions test/unit/exec/graphql.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { describe, test, expect, vi } from 'vitest';
import { parseParams, execGraphql } from '#lib/exec/graphql.js';

describe('parseParams', () => {
test('returns {} when nothing provided', () => {
expect(parseParams(undefined)).toEqual({});
expect(parseParams(null)).toEqual({});
expect(parseParams('')).toEqual({});
});

test('parses a JSON object of variables', () => {
expect(parseParams('{"id":"42","tags":["a","b"]}')).toEqual({ id: '42', tags: ['a', 'b'] });
});

test('throws on invalid JSON', () => {
expect(() => parseParams('{not json')).toThrow(/expected a JSON object of variables/);
});

test('throws when JSON is not an object', () => {
expect(() => parseParams('[1,2]')).toThrow(/expected a JSON object of variables/);
expect(() => parseParams('"foo"')).toThrow(/expected a JSON object of variables/);
expect(() => parseParams('null')).toThrow(/expected a JSON object of variables/);
});
});

describe('execGraphql', () => {
const baseDeps = (graphImpl) => {
const graph = vi.fn(graphImpl || (async () => ({ data: { ok: true } })));
return {
graph,
deps: {
GatewayCtor: class {
constructor(auth) {
this.auth = auth;
}
graph(body) {
return graph(body);
}
},
fetchSettingsFn: vi.fn().mockResolvedValue({ url: 'https://x', token: 't', email: 'e' }),
isProductionEnvironmentFn: vi.fn().mockReturnValue(false),
confirmProductionExecutionFn: vi.fn().mockResolvedValue(true),
fileReader: { existsSync: vi.fn().mockReturnValue(true), readFileSync: vi.fn() },
},
};
};

test('passes parsed params as GraphQL variables', async () => {
const { graph, deps } = baseDeps();
const { response } = await execGraphql({
environment: 'dev',
query: 'query q($id: ID) { record(id: $id) { id } }',
params: '{"id":"42"}',
deps,
});

expect(graph).toHaveBeenCalledWith({
query: 'query q($id: ID) { record(id: $id) { id } }',
variables: { id: '42' },
});
expect(response.data.ok).toBe(true);
});

test('defaults variables to {} when no params given', async () => {
const { graph, deps } = baseDeps();
await execGraphql({ environment: 'dev', query: 'query { ok }', deps });
expect(graph).toHaveBeenCalledWith({ query: 'query { ok }', variables: {} });
});

test('reads the query from --file', async () => {
const { graph, deps } = baseDeps();
deps.fileReader.readFileSync.mockReturnValue('query FromFile { ok }');
await execGraphql({ environment: 'dev', file: '/tmp/q.graphql', params: '{"x":1}', deps });
expect(graph).toHaveBeenCalledWith({ query: 'query FromFile { ok }', variables: { x: 1 } });
});

test('throws when neither query nor file resolves a query', async () => {
const { deps } = baseDeps();
await expect(execGraphql({ environment: 'dev', deps })).rejects.toThrow(/missing required argument 'graphql'/);
});

test('propagates invalid --params error before any API call', async () => {
const { graph, deps } = baseDeps();
await expect(
execGraphql({ environment: 'dev', query: 'query { ok }', params: '{bad', deps })
).rejects.toThrow(/expected a JSON object of variables/);
expect(graph).not.toHaveBeenCalled();
});

test('returns the raw GraphQL response including errors', async () => {
const { deps } = baseDeps(async () => ({ errors: [{ message: 'boom' }], data: null }));
const { response } = await execGraphql({ environment: 'dev', query: 'query { ok }', deps });
expect(response.errors[0].message).toBe('boom');
});

test('cancels on production when confirmation is declined', async () => {
const { graph, deps } = baseDeps();
deps.isProductionEnvironmentFn.mockReturnValue(true);
deps.confirmProductionExecutionFn.mockResolvedValue(false);
const result = await execGraphql({ environment: 'production', query: 'query { ok }', deps });
expect(result.cancelled).toBe(true);
expect(graph).not.toHaveBeenCalled();
});
});
Loading
Loading