Skip to content
Merged
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
60 changes: 60 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
name: Build and Test

on:
pull_request:
branches:
- '**'
push:
branches:
- main

jobs:
build-and-test:
runs-on: ubuntu-latest
permissions:
contents: write
actions: read
checks: write
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0

- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: 22

- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 8.15.5
run_install: false

- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV

- name: Cache pnpm dependencies
uses: actions/cache@v3
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Typecheck
run: pnpm typecheck

- name: Lint
run: pnpm lint

- name: Build
run: pnpm build

- name: Test
run: pnpm test
2 changes: 2 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
import prettierRecommended from 'eslint-plugin-prettier/recommended';

export default tseslint.config(
eslint.configs.recommended,
Expand All @@ -15,4 +16,5 @@ export default tseslint.config(
},
},
},
prettierRecommended,
);
46 changes: 23 additions & 23 deletions src/__tests__/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@ describe('Persisted Query Plugin Integration Tests', () => {
// Load test schema and documents
const schemaPath = join(__dirname, 'fixtures/test-schema.graphql');
const documentsPath = join(__dirname, 'fixtures/test-documents.graphql');

const schemaDoc = readFileSync(schemaPath, 'utf8');
const documentsDoc = readFileSync(documentsPath, 'utf8');

const schema = buildSchema(schemaDoc);
const documentNode = parse(documentsDoc);

const documents: Types.DocumentFile[] = [
{
document: documentNode,
Expand All @@ -28,65 +28,65 @@ describe('Persisted Query Plugin Integration Tests', () => {
const result = plugin(schema, documents, { output: 'client' });
// We know result is a string, but TypeScript sees it as Promisable<string>
const manifest = JSON.parse(result as string);

// Use snapshot for client manifest
expect(manifest).toMatchSnapshot();
});
});

test('generates valid client manifest with algorithm prefix', () => {
const result = plugin(schema, documents, {
const result = plugin(schema, documents, {
output: 'client',
includeAlgorithmPrefix: true
includeAlgorithmPrefix: true,
});
// We know result is a string, but TypeScript sees it as Promisable<string>
const manifest = JSON.parse(result as string);

// Use snapshot for client manifest with algorithm prefix
expect(manifest).toMatchSnapshot();
});
});

test('generates valid client manifest with custom algorithm', () => {
const result = plugin(schema, documents, {
const result = plugin(schema, documents, {
output: 'client',
algorithm: 'md5'
algorithm: 'md5',
});
// We know result is a string, but TypeScript sees it as Promisable<string>
const manifest = JSON.parse(result as string);

// Use snapshot for client manifest with custom algorithm
expect(manifest).toMatchSnapshot();
});
});

describe('Server Manifest Generation', () => {
test('generates valid server manifest with default options', () => {
const result = plugin(schema, documents, { output: 'server' });
// We know result is a string, but TypeScript sees it as Promisable<string>
const manifest = JSON.parse(result as string);

// Use snapshot for server manifest
expect(manifest).toMatchSnapshot();
});
});

describe('Error Handling', () => {
test('throws error on missing operation names', () => {
const docWithUnnamedOperation = parse(`
query {
hello
}
`);

const docs: Types.DocumentFile[] = [
{
document: docWithUnnamedOperation,
location: 'test.graphql',
},
];
expect(() =>
plugin(schema, docs, { output: 'client' })
).toThrow('OperationDefinition missing name');

expect(() => plugin(schema, docs, { output: 'client' })).toThrow(
'OperationDefinition missing name',
);
});
});
});
});
31 changes: 17 additions & 14 deletions src/core/generator.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { DocumentNode, visit } from 'graphql';
import {
import {
PluginConfig,
ClientOperationListManifest,
ServerOperationListManifest,
PersistedQueryManifestOperation,
ProcessedOperation
ProcessedOperation,
} from '../types';
import { createHash } from '../utils/hashing';
import { addTypenameToDocument } from '../utils/transforms';
Expand All @@ -13,17 +13,20 @@ import { printDefinitions } from '../utils/transforms';

/**
* Process documents and generate operation hashes with their details
*
*
* @param docs - Array of GraphQL document nodes
* @param config - Plugin configuration
* @returns Array of processed operations with their details
* @throws Error if an operation is missing a name
*/
function processOperations(docs: DocumentNode[], config: PluginConfig): ProcessedOperation[] {
function processOperations(
docs: DocumentNode[],
config: PluginConfig,
): ProcessedOperation[] {
// Add __typename to all selection sets
const processedDocs = docs.map(addTypenameToDocument);
const operations: ProcessedOperation[] = [];

const knownFragments = findFragments(processedDocs);

for (const doc of processedDocs) {
Expand All @@ -43,14 +46,14 @@ function processOperations(docs: DocumentNode[], config: PluginConfig): Processe
const query = printDefinitions([def, ...usedFragments.values()]);

const hash = createHash(query, config);

operations.push({
name: operationName,
hash,
type: def.operation,
query,
definition: def,
fragments: Array.from(usedFragments.values())
fragments: Array.from(usedFragments.values()),
});
},
},
Expand All @@ -63,7 +66,7 @@ function processOperations(docs: DocumentNode[], config: PluginConfig): Processe
/**
* Generates a client-side persisted query manifest
* Client manifests map operation names to their hash
*
*
* @param docs - Array of GraphQL document nodes
* @param config - Plugin configuration
* @returns Client-side manifest object
Expand All @@ -75,7 +78,7 @@ export function generateClientManifest(
): ClientOperationListManifest {
const operations = processOperations(docs, config);
const manifest: ClientOperationListManifest = {};

for (const operation of operations) {
manifest[operation.name] = operation.hash;
}
Expand All @@ -86,7 +89,7 @@ export function generateClientManifest(
/**
* Generates a server-side persisted query manifest
* Server manifests map query hashes to operation details
*
*
* @param docs - Array of GraphQL document nodes
* @param config - Plugin configuration
* @returns Server-side manifest object
Expand All @@ -97,22 +100,22 @@ export function generateServerManifest(
config: PluginConfig,
): ServerOperationListManifest {
const operations = processOperations(docs, config);

const manifest: ServerOperationListManifest = {
format: 'apollo-persisted-query-manifest',
version: 1,
operations: {},
};

for (const operation of operations) {
const operationDetails: PersistedQueryManifestOperation = {
type: operation.type,
name: operation.name,
body: operation.query,
};

manifest.operations[operation.hash] = operationDetails;
}

return manifest;
}
}
2 changes: 1 addition & 1 deletion src/core/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export * from './generator';
export * from './plugin';
export * from './plugin';
24 changes: 14 additions & 10 deletions src/core/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,17 @@ import { generateClientManifest, generateServerManifest } from './generator';

/**
* GraphQL CodeGen plugin for generating persisted operation manifests
*
*
* @param _schema - GraphQL schema (unused)
* @param documents - GraphQL documents to process
* @param config - Plugin configuration
* @returns JSON string representation of the generated manifest
* @throws Error if no documents are provided or output format is not specified
*
*
* For GraphQL over HTTP specification compliance, set `includeAlgorithmPrefix: true`
* to enable the "Prefixed Document Identifier" format (e.g., `sha256:abc123...`).
*/
export const plugin: PersistedQueryPlugin = (
_schema,
documents,
config,
) => {
export const plugin: PersistedQueryPlugin = (_schema, documents, config) => {
// Validate input documents
if (
!documents ||
Expand All @@ -35,10 +31,18 @@ export const plugin: PersistedQueryPlugin = (

// Generate appropriate manifest format based on configuration
if (config.output === 'client') {
return JSON.stringify(generateClientManifest(documentNodes, config), null, ' ');
return JSON.stringify(
generateClientManifest(documentNodes, config),
null,
' ',
);
} else if (config.output === 'server') {
return JSON.stringify(generateServerManifest(documentNodes, config), null, ' ');
return JSON.stringify(
generateServerManifest(documentNodes, config),
null,
' ',
);
} else {
throw new Error("Must configure output to 'server' or 'client'");
}
};
};
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
/**
* GraphQL CodeGen Plugin for Persisted Query Lists
*
*
* The plugin can generate manifests in two formats:
* - Client format: Maps operation names to query hashes
* - Server format: Maps query hashes to full operation details
*/

export * from './types';
export * from './core';
export * from './utils';
export * from './utils';
14 changes: 10 additions & 4 deletions src/types/index.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import type { FragmentDefinitionNode, OperationDefinitionNode, OperationTypeNode } from 'graphql';
import type {
FragmentDefinitionNode,
OperationDefinitionNode,
OperationTypeNode,
} from 'graphql';
import type { PluginFunction } from '@graphql-codegen/plugin-helpers';

/**
* Configuration for the persisted query generator plugin
*/
export interface PluginConfig {
/**
/**
* Output format - 'server' generates hash->operation mapping,
* 'client' generates operation->hash mapping
*/
output: 'server' | 'client';

/**
* Hash algorithm to use (defaults to 'sha256')
*/
Expand Down Expand Up @@ -54,7 +58,9 @@ export type ClientOperationListManifest = Record<string, string>;
/**
* Union type for both manifest formats
*/
export type OperationListManifest = ServerOperationListManifest | ClientOperationListManifest;
export type OperationListManifest =
| ServerOperationListManifest
| ClientOperationListManifest;

/**
* Internal type for a GraphQL definition
Expand Down
Loading