mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-29 01:39:24 +08:00
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:
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user