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
Original file line number Diff line number Diff line change
Expand Up @@ -1122,12 +1122,6 @@ export class QueryEffects {
const { resolvedFiles } = this.gqlService.normalizeFiles(
response.data.variables.files
);
if (resolvedFiles.length) {
this.notifyService.error(
'This is not currently available with file variables'
);
return EMPTY;
}

try {
const curlCommand = generateCurl({
Expand All @@ -1143,6 +1137,7 @@ export class QueryEffects {
query,
variables: parseJson(variables),
},
files: resolvedFiles,
});
debug.log(curlCommand);
copyToClipboard(curlCommand);
Expand Down
107 changes: 107 additions & 0 deletions packages/altair-app/src/app/modules/altair/utils/curl.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,111 @@ describe('generateCurl', () => {
`curl 'https://altairgraphql.dev' -H 'Accept-Encoding: gzip, deflate, br' -H 'Content-Type: application/json' -H 'Accept: application/json' -H 'Connection: keep-alive' -H 'Origin: http://localhost' -H 'X-api-token: xyz' --data-binary '{"x":"1"}' --compressed`
);
});

it('generates multipart/form-data request with single file upload', () => {
// Create a mock File object
const mockFile = new File(['test content'], 'test.txt', { type: 'text/plain' });

const res = generateCurl({
url: 'https://altairgraphql.dev/graphql',
data: {
query: 'mutation($file: Upload!) { uploadFile(file: $file) }',
variables: {},
},
headers: {
'X-api-token': 'xyz',
},
method: 'POST',
files: [
{
name: 'file',
data: mockFile,
},
],
});

// Should not include Content-Type header (curl will set it with boundary)
expect(res).not.toContain('Content-Type');

// Should include operations field with null for file variable
expect(res).toContain("-F 'operations=");
expect(res).toContain('"variables":{"file":null}');

// Should include map field
expect(res).toContain("-F 'map=");
expect(res).toContain('"0":["variables.file"]');

// Should include file field
expect(res).toContain("-F '0=@test.txt'");

// Should still include other headers
expect(res).toContain("'X-api-token: xyz'");
});

it('generates multipart/form-data request with multiple file uploads', () => {
const mockFile1 = new File(['test content 1'], 'test1.txt', { type: 'text/plain' });
const mockFile2 = new File(['test content 2'], 'test2.txt', { type: 'text/plain' });

const res = generateCurl({
url: 'https://altairgraphql.dev/graphql',
data: {
query: 'mutation($files: [Upload!]!) { uploadFiles(files: $files) }',
variables: {},
},
method: 'POST',
files: [
{
name: 'files.0',
data: mockFile1,
},
{
name: 'files.1',
data: mockFile2,
},
],
});

// Should include operations with nulls for both files
expect(res).toContain('"variables":{"files":{"0":null,"1":null}}');

// Should include map for both files
expect(res).toContain('"0":["variables.files.0"]');
expect(res).toContain('"1":["variables.files.1"]');

// Should include both file fields
expect(res).toContain("-F '0=@test1.txt'");
expect(res).toContain("-F '1=@test2.txt'");
});

it('generates multipart/form-data request with nested file variables', () => {
const mockFile = new File(['test content'], 'document.pdf', { type: 'application/pdf' });

const res = generateCurl({
url: 'https://altairgraphql.dev/graphql',
data: {
query: 'mutation($input: CreateInput!) { create(input: $input) }',
variables: {
input: {
name: 'Test',
},
},
},
method: 'POST',
files: [
{
name: 'input.document',
data: mockFile,
},
],
});

// Should preserve existing variables and set file to null
expect(res).toContain('"variables":{"input":{"name":"Test","document":null}}');

// Should map to correct nested path
expect(res).toContain('"0":["variables.input.document"]');

// Should include file field
expect(res).toContain("-F '0=@document.pdf'");
});
});
73 changes: 69 additions & 4 deletions packages/altair-app/src/app/modules/altair/utils/curl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,17 @@ import { IDictionary } from '../interfaces/shared';
export const parseCurlToObj = async (...args: any[]) =>
(await import('curlup')).parseCurl(...args);

interface FileVariable {
name: string;
data: File;
}

interface GenerateCurlOpts {
url: string;
method?: 'POST' | 'GET' | 'PUT' | 'DELETE';
headers?: object;
data?: { [key: string]: string };
files?: FileVariable[];
}

const getCurlHeaderString = (header: { key: string; value: string }) => {
Expand All @@ -34,9 +40,11 @@ const buildUrl = (url: string, params?: { [key: string]: string }) => {
};

export const generateCurl = (opts: GenerateCurlOpts) => {
const hasFiles = opts.files && opts.files.length > 0;

const defaultHeaders = {
'Accept-Encoding': 'gzip, deflate, br',
'Content-Type': 'application/json',
'Content-Type': hasFiles ? 'multipart/form-data' : 'application/json',
Accept: 'application/json',
Connection: 'keep-alive',
Origin: location.origin,
Expand All @@ -45,7 +53,14 @@ export const generateCurl = (opts: GenerateCurlOpts) => {
const method = opts.method || 'POST';

const headers: IDictionary<string> = { ...defaultHeaders, ...opts.headers };
const headerString = mapToKeyValueList(headers).map(getCurlHeaderString).join(' ');

// When using files, we should not set Content-Type header manually
// curl will set it automatically with the boundary
const headersToUse = hasFiles
? Object.fromEntries(Object.entries(headers).filter(([key]) => key.toLowerCase() !== 'content-type'))
: headers;

const headerString = mapToKeyValueList(headersToUse).map(getCurlHeaderString).join(' ');

const url = method === 'GET' ? buildUrl(opts.url, opts.data) : opts.url;

Expand All @@ -58,9 +73,59 @@ export const generateCurl = (opts: GenerateCurlOpts) => {
curlParts.push(`${headerString}`);

if (method !== 'GET') {
const dataBinary = `--data-binary '${JSON.stringify(opts.data)}'`;
curlParts.push(dataBinary);
if (hasFiles) {
// Handle file uploads using multipart/form-data
// Following the GraphQL multipart request spec:
// https://github.com/jaydenseric/graphql-multipart-request-spec

// Create file map for multipart request
const fileMap: Record<string, string[]> = {};
const dataWithNulls = JSON.parse(JSON.stringify(opts.data)); // Deep copy

// Ensure variables object exists
if (!dataWithNulls.variables) {
dataWithNulls.variables = {};
}

opts.files.forEach((file, i) => {
// Set file variables to null in the variables object
const variablePath = file.name;
setVariableToNull(dataWithNulls.variables, variablePath);
fileMap[i] = [`variables.${variablePath}`];
});

// Add operations field (GraphQL query and variables with nulls for files)
curlParts.push(`-F 'operations=${JSON.stringify(dataWithNulls)}'`);

// Add map field (mapping of file indices to variable paths)
curlParts.push(`-F 'map=${JSON.stringify(fileMap)}'`);

// Add file fields
opts.files.forEach((file, i) => {
const fileName = file.data.name || `file${i}`;
curlParts.push(`-F '${i}=@${fileName}'`);
});
} else {
const dataBinary = `--data-binary '${JSON.stringify(opts.data)}'`;
curlParts.push(dataBinary);
}
}

return `curl ${curlParts.join(' ')} --compressed`;
};

// Helper function to set nested properties to null
const setVariableToNull = (obj: any, path: string) => {
const parts = path.split('.');
let current = obj;

for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (!(part in current)) {
current[part] = {};
}
current = current[part];
}

current[parts[parts.length - 1]] = null;
};