diff --git a/packages/nodes-base/credentials/GristApi.credentials.ts b/packages/nodes-base/credentials/GristApi.credentials.ts index 514b15f19d4..bf0b9146091 100644 --- a/packages/nodes-base/credentials/GristApi.credentials.ts +++ b/packages/nodes-base/credentials/GristApi.credentials.ts @@ -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.', }, ]; } diff --git a/packages/nodes-base/nodes/Grist/GenericFunctions.ts b/packages/nodes-base/nodes/Grist/GenericFunctions.ts index d085579f9cd..afe79157bf7 100644 --- a/packages/nodes-base/nodes/Grist/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Grist/GenericFunctions.ts @@ -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('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('gristApi'); const options: IRequestOptions = { headers: { - Authorization: `Bearer ${apiKey}`, + Authorization: `Bearer ${credentials.apiKey}`, }, method, - uri: gristapiurl, + uri: `${gristBaseUrl(credentials)}/api${endpoint}`, qs, body, json: true, diff --git a/packages/nodes-base/nodes/Grist/Grist.node.ts b/packages/nodes-base/nodes/Grist/Grist.node.ts index 2e09e379778..dcce1e1c419 100644 --- a/packages/nodes-base/nodes/Grist/Grist.node.ts +++ b/packages/nodes-base/nodes/Grist/Grist.node.ts @@ -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 { - 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', diff --git a/packages/nodes-base/nodes/Grist/test/Grist.node.test.ts b/packages/nodes-base/nodes/Grist/test/Grist.node.test.ts new file mode 100644 index 00000000000..fd89b9f5516 --- /dev/null +++ b/packages/nodes-base/nodes/Grist/test/Grist.node.test.ts @@ -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(); + 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(); + 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'); + }); +}); diff --git a/packages/nodes-base/nodes/Grist/test/gristBaseUrl.test.ts b/packages/nodes-base/nodes/Grist/test/gristBaseUrl.test.ts new file mode 100644 index 00000000000..a7dc536265e --- /dev/null +++ b/packages/nodes-base/nodes/Grist/test/gristBaseUrl.test.ts @@ -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'); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Grist/types.ts b/packages/nodes-base/nodes/Grist/types.ts index 2ff8c2406ce..1cb389e9073 100644 --- a/packages/nodes-base/nodes/Grist/types.ts +++ b/packages/nodes-base/nodes/Grist/types.ts @@ -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; };