feat(Grist Node): Use a single Grist URL field in the credential (#34190)

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Fitzpatrick
2026-07-20 04:42:47 -04:00
committed by GitHub
parent c84ada02ae
commit d6d7d93df3
6 changed files with 168 additions and 71 deletions
@@ -15,54 +15,17 @@ export class GristApi implements ICredentialType {
typeOptions: { password: true },
default: '',
required: true,
description:
'In Grist, open the account menu (top right) > Account settings > Developer to create or copy your API key',
},
{
displayName: 'Plan Type',
name: 'planType',
type: 'options',
default: 'free',
options: [
{
name: 'Free',
value: 'free',
},
{
name: 'Paid',
value: 'paid',
},
{
name: 'Self-Hosted',
value: 'selfHosted',
},
],
},
{
displayName: 'Custom Subdomain',
name: 'customSubdomain',
displayName: 'Grist URL',
name: 'url',
type: 'string',
default: '',
required: true,
description: 'Custom subdomain of your team',
displayOptions: {
show: {
planType: ['paid'],
},
},
},
{
displayName: 'Self-Hosted URL',
name: 'selfHostedUrl',
type: 'string',
default: '',
placeholder: 'http://localhost:8484',
default: 'https://api.getgrist.com',
required: true,
description:
'URL of your Grist instance. Include http/https without /api and no trailing slash.',
displayOptions: {
show: {
planType: ['selfHosted'],
},
},
'Defaults to hosted Grist. Use https://YOUR_TEAM.getgrist.com for a single team, or your own URL if self-managed. Do not include /api.',
},
];
}
@@ -15,6 +15,34 @@ import type {
GristSortProperties,
} from './types';
// A trailing slash or a trailing `/api` are both easy to paste in from a browser or the
// API docs. Request paths append `/api` themselves, so the base URL needs neither.
function normalizeBaseUrl(url: string): string {
return url.replace(/\/$/, '').replace(/\/api$/, '');
}
// Fallback for API-key credentials created before the single `url` field: self-hosted
// instances stored a full URL, teams stored a subdomain. Defaults to the SaaS API host,
// which serves every hosted account.
function gristLegacyBaseUrl(credentials: GristCredentials): string {
if (credentials.selfHostedUrl) {
return normalizeBaseUrl(credentials.selfHostedUrl);
}
if (credentials.customSubdomain) {
return `https://${credentials.customSubdomain}.getgrist.com`;
}
return 'https://api.getgrist.com';
}
// Resolve the Grist server base URL. Credentials store a single `url`; older ones fall
// back to their legacy fields.
export function gristBaseUrl(credentials: GristCredentials): string {
if (credentials.url) {
return normalizeBaseUrl(credentials.url);
}
return gristLegacyBaseUrl(credentials);
}
export async function gristApiRequest(
this: IExecuteFunctions | ILoadOptionsFunctions,
method: IHttpRequestMethods,
@@ -22,22 +50,14 @@ export async function gristApiRequest(
body: IDataObject | number[] = {},
qs: IDataObject = {},
) {
const { apiKey, planType, customSubdomain, selfHostedUrl } =
await this.getCredentials<GristCredentials>('gristApi');
const gristapiurl =
planType === 'free'
? `https://docs.getgrist.com/api${endpoint}`
: planType === 'paid'
? `https://${customSubdomain}.getgrist.com/api${endpoint}`
: `${selfHostedUrl}/api${endpoint}`;
const credentials = await this.getCredentials<GristCredentials>('gristApi');
const options: IRequestOptions = {
headers: {
Authorization: `Bearer ${apiKey}`,
Authorization: `Bearer ${credentials.apiKey}`,
},
method,
uri: gristapiurl,
uri: `${gristBaseUrl(credentials)}/api${endpoint}`,
qs,
body,
json: true,
+13 -15
View File
@@ -14,6 +14,7 @@ import {
import {
gristApiRequest,
gristBaseUrl,
parseAutoMappedInputs,
parseDefinedFields,
parseFilterProperties,
@@ -73,30 +74,27 @@ export class Grist implements INodeType {
this: ICredentialTestFunctions,
credential: ICredentialsDecrypted,
): Promise<INodeCredentialTestResult> {
const { apiKey, planType, customSubdomain, selfHostedUrl } =
credential.data as GristCredentials;
const endpoint = '/orgs';
const gristapiurl =
planType === 'free'
? `https://docs.getgrist.com/api${endpoint}`
: planType === 'paid'
? `https://${customSubdomain}.getgrist.com/api${endpoint}`
: `${selfHostedUrl}/api${endpoint}`;
const credentials = credential.data as GristCredentials;
const options: IRequestOptions = {
headers: {
Authorization: `Bearer ${apiKey}`,
Authorization: `Bearer ${credentials.apiKey}`,
},
method: 'GET',
uri: gristapiurl,
qs: { limit: 1 },
uri: `${gristBaseUrl(credentials)}/api/orgs`,
json: true,
};
try {
await this.helpers.request(options);
// A valid token can still grant zero accessible orgs (e.g. nothing shared); treat
// that as a failing test rather than a misleading success.
const orgs = await this.helpers.request(options);
if (!Array.isArray(orgs) || orgs.length === 0) {
return {
status: 'Error',
message: 'Connected, but no Grist organizations are accessible to this account.',
};
}
return {
status: 'OK',
message: 'Authentication successful',
@@ -0,0 +1,64 @@
import type { ICredentialsDecrypted, ICredentialTestFunctions } from 'n8n-workflow';
import { mock } from 'vitest-mock-extended';
import { Grist } from '../Grist.node';
describe('Grist credentialTest', () => {
const run = async (
orgs: unknown,
data: ICredentialsDecrypted['data'] = { apiKey: 'k', url: 'https://api.getgrist.com' },
) => {
const request = vi.fn().mockResolvedValue(orgs);
const testFns = mock<ICredentialTestFunctions>();
testFns.helpers = { ...testFns.helpers, request };
// Plain object (not a deep mock) so an absent `url` reads as undefined rather than an auto-mock.
const credential = { data } as unknown as ICredentialsDecrypted;
const result = await new Grist().methods.credentialTest.gristApiTest.call(testFns, credential);
return { result, request };
};
it('passes when at least one org is accessible', async () => {
const { result, request } = await run([{ id: 1, name: 'Personal' }]);
expect(result.status).toBe('OK');
expect(request.mock.calls[0][0].uri).toBe('https://api.getgrist.com/api/orgs');
expect(request.mock.calls[0][0].headers.Authorization).toBe('Bearer k');
});
it('fails when no orgs are accessible', async () => {
const { result } = await run([]);
expect(result.status).toBe('Error');
expect(result.message).toContain('no Grist organizations are accessible');
});
it('fails when the response is not an array', async () => {
const { result } = await run({ unexpected: true });
expect(result.status).toBe('Error');
});
it('reports the request error message on failure', async () => {
const request = vi.fn().mockRejectedValue(new Error('Unauthorized'));
const testFns = mock<ICredentialTestFunctions>();
testFns.helpers = { ...testFns.helpers, request };
const credential = {
data: { apiKey: 'bad', url: 'https://api.getgrist.com' },
} as unknown as ICredentialsDecrypted;
const result = await new Grist().methods.credentialTest.gristApiTest.call(testFns, credential);
expect(result.status).toBe('Error');
expect(result.message).toBe('Unauthorized');
});
it('resolves the base URL from a legacy credential without a url', async () => {
const { request } = await run([{ id: 1 }], {
apiKey: 'k',
selfHostedUrl: 'http://localhost:8484',
});
expect(request.mock.calls[0][0].uri).toBe('http://localhost:8484/api/orgs');
});
});
@@ -0,0 +1,50 @@
import { gristBaseUrl } from '../GenericFunctions';
describe('Grist gristBaseUrl', () => {
it('uses the unified url field, stripping a trailing slash', () => {
expect(gristBaseUrl({ url: 'https://api.getgrist.com' })).toBe('https://api.getgrist.com');
expect(gristBaseUrl({ url: 'https://team.getgrist.com/' })).toBe('https://team.getgrist.com');
expect(gristBaseUrl({ url: 'http://localhost:8484' })).toBe('http://localhost:8484');
});
it('strips a trailing /api, which request paths add themselves', () => {
expect(gristBaseUrl({ url: 'http://localhost:8484/api' })).toBe('http://localhost:8484');
expect(gristBaseUrl({ url: 'http://localhost:8484/api/' })).toBe('http://localhost:8484');
});
it('keeps a host whose name merely ends in api', () => {
expect(gristBaseUrl({ url: 'https://api.getgrist.com' })).toBe('https://api.getgrist.com');
expect(gristBaseUrl({ url: 'https://grist-api.example.com' })).toBe(
'https://grist-api.example.com',
);
});
describe('legacy credentials without a url', () => {
it('resolves a stored self-hosted URL, stripping a trailing slash', () => {
expect(gristBaseUrl({ selfHostedUrl: 'http://localhost:8484/' })).toBe(
'http://localhost:8484',
);
});
it('strips a trailing /api from a stored self-hosted URL', () => {
expect(gristBaseUrl({ selfHostedUrl: 'http://localhost:8484/api' })).toBe(
'http://localhost:8484',
);
});
it('builds the team host from a stored subdomain', () => {
expect(gristBaseUrl({ customSubdomain: 'acme' })).toBe('https://acme.getgrist.com');
});
it('falls back to the SaaS API host (covers the old free plan)', () => {
expect(gristBaseUrl({ apiKey: 'k' })).toBe('https://api.getgrist.com');
expect(gristBaseUrl({})).toBe('https://api.getgrist.com');
});
it('prefers a self-hosted URL over a subdomain when both are present', () => {
expect(
gristBaseUrl({ selfHostedUrl: 'https://grist.example.com', customSubdomain: 'acme' }),
).toBe('https://grist.example.com');
});
});
});
+4 -2
View File
@@ -1,6 +1,8 @@
export type GristCredentials = {
apiKey: string;
planType: 'free' | 'paid' | 'selfHosted';
apiKey?: string;
url?: string;
// Legacy API-key credential fields, superseded by `url` (no credential migration exists,
// so these are still read as a fallback for connections created before the unified field).
customSubdomain?: string;
selfHostedUrl?: string;
};