Skip to content
Open
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
2 changes: 1 addition & 1 deletion src/__tests__/commands/parse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ describe('executeParse', () => {
});

it('posts to /v2/parse without auth when using the default cloud API with no key', async () => {
initializeConfig({ apiUrl: 'https://api.firecrawl.dev' });
initializeConfig({ apiUrl: 'https://api.firecrawl.dev///' });

const result = await executeParse({ file: filePath });

Expand Down
45 changes: 45 additions & 0 deletions src/__tests__/utils/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { keylessGet, keylessRequest } from '../../utils/client';
import { initializeConfig, resetConfig } from '../../utils/config';

vi.mock('../../utils/credentials', () => ({
loadCredentials: vi.fn(() => null),
}));

describe('keyless requests', () => {
const fetchMock = vi.fn();

beforeEach(() => {
resetConfig();
initializeConfig({ apiUrl: 'https://api.firecrawl.dev///' });
fetchMock.mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ success: true }),
});
vi.stubGlobal('fetch', fetchMock);
});

afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
resetConfig();
});

it('joins POST paths after every trailing slash is removed', async () => {
await keylessRequest('/v2/scrape', { url: 'https://example.com' });

expect(fetchMock).toHaveBeenCalledWith(
'https://api.firecrawl.dev/v2/scrape',
expect.objectContaining({ method: 'POST' })
);
});

it('joins GET paths after every trailing slash is removed', async () => {
await keylessGet('/v2/search/research');

expect(fetchMock).toHaveBeenCalledWith(
'https://api.firecrawl.dev/v2/search/research',
expect.objectContaining({ method: 'GET' })
);
});
});
23 changes: 22 additions & 1 deletion src/__tests__/utils/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
validateConfig,
isCustomApiUrl,
} from '../../utils/config';
import { getClient, resetClient } from '../../utils/client';
import { getClient, isKeylessMode, resetClient } from '../../utils/client';
import * as credentials from '../../utils/credentials';

// Mock credentials module
Expand Down Expand Up @@ -240,6 +240,12 @@ describe('Config Fallback Priority', () => {
expect(isCustomApiUrl()).toBe(false);
});

it('should ignore trailing slashes on the default cloud API URL', () => {
initializeConfig({ apiUrl: 'https://api.firecrawl.dev/' });
expect(isCustomApiUrl()).toBe(false);
expect(isCustomApiUrl('https://api.firecrawl.dev///')).toBe(false);
});

it('should return true for custom API URLs', () => {
initializeConfig({ apiUrl: 'http://localhost:3002' });
expect(isCustomApiUrl()).toBe(true);
Expand All @@ -254,6 +260,16 @@ describe('Config Fallback Priority', () => {
initializeConfig({ apiUrl: 'https://api.firecrawl.dev' });
expect(isCustomApiUrl('http://localhost:3002')).toBe(true);
});

it('should keep custom API URLs custom when they have trailing slashes', () => {
expect(isCustomApiUrl('http://localhost:3002/')).toBe(true);
});
});

describe('keyless mode with cloud API URLs', () => {
it('should allow a trailing slash on the default cloud API URL', () => {
expect(isKeylessMode(undefined, 'https://api.firecrawl.dev/')).toBe(true);
});
});

describe('validateConfig with custom API URLs', () => {
Expand All @@ -268,6 +284,11 @@ describe('Config Fallback Priority', () => {
expect(() => validateConfig()).toThrow('API key is required');
});

it('should require API key for cloud API URL with a trailing slash', () => {
initializeConfig({ apiUrl: 'https://api.firecrawl.dev/' });
expect(() => validateConfig()).toThrow('API key is required');
});

it('should not throw when API key is provided for cloud API', () => {
initializeConfig({
apiUrl: 'https://api.firecrawl.dev',
Expand Down
4 changes: 2 additions & 2 deletions src/commands/interact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/

import { getClient, isKeylessMode } from '../utils/client';
import { getConfig, validateConfig } from '../utils/config';
import { getConfig, normalizeApiUrl, validateConfig } from '../utils/config';
import {
getScrapeId,
loadInteractSession,
Expand Down Expand Up @@ -46,7 +46,7 @@ function resolveApiConfig(options: { apiKey?: string; apiUrl?: string }) {
validateConfig(apiKey);
}
const apiUrl = options.apiUrl || config.apiUrl || 'https://api.firecrawl.dev';
return { apiKey, apiUrl: apiUrl.replace(/\/$/, ''), keyless };
return { apiKey, apiUrl: normalizeApiUrl(apiUrl), keyless };
}

/**
Expand Down
7 changes: 3 additions & 4 deletions src/commands/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import type { FormatOption } from 'firecrawl';
import type { ParseOptions, ParseResult } from '../types/parse';
import type { ScrapeFormat } from '../types/scrape';
import { getClient, isKeylessMode } from '../utils/client';
import { getConfig, validateConfig } from '../utils/config';
import { getConfig, normalizeApiUrl, validateConfig } from '../utils/config';
import { handleScrapeOutput } from '../utils/output';

const DEFAULT_API_URL = 'https://api.firecrawl.dev';
Expand Down Expand Up @@ -162,9 +162,8 @@ export async function executeParse(
validateConfig(apiKey);
}

const apiUrl = (options.apiUrl || config.apiUrl || DEFAULT_API_URL).replace(
/\/$/,
''
const apiUrl = normalizeApiUrl(
options.apiUrl || config.apiUrl || DEFAULT_API_URL
);

const buffer = fs.readFileSync(filePath);
Expand Down
5 changes: 3 additions & 2 deletions src/utils/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
isCustomApiUrl,
validateConfig,
updateConfig,
normalizeApiUrl,
type GlobalConfig,
} from './config';

Expand All @@ -33,7 +34,7 @@ export async function keylessRequest(
path: string,
body: Record<string, unknown>
): Promise<any> {
const apiUrl = (getConfig().apiUrl || DEFAULT_API_URL).replace(/\/$/, '');
const apiUrl = normalizeApiUrl(getConfig().apiUrl || DEFAULT_API_URL);
const response = await fetch(`${apiUrl}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
Expand All @@ -49,7 +50,7 @@ export async function keylessRequest(
}

export async function keylessGet(path: string): Promise<any> {
const apiUrl = (getConfig().apiUrl || DEFAULT_API_URL).replace(/\/$/, '');
const apiUrl = normalizeApiUrl(getConfig().apiUrl || DEFAULT_API_URL);
const response = await fetch(`${apiUrl}${path}`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
Expand Down
7 changes: 6 additions & 1 deletion src/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,17 @@ export function getApiKey(providedKey?: string): string | undefined {

const DEFAULT_API_URL = 'https://api.firecrawl.dev';

/** Remove trailing slashes before comparing or joining an API base URL. */
export function normalizeApiUrl(apiUrl: string): string {
return apiUrl.replace(/\/+$/, '');
}

/**
* Check if using a custom (non-cloud) API URL
*/
export function isCustomApiUrl(apiUrl?: string): boolean {
const url = apiUrl || globalConfig.apiUrl;
return !!url && url !== DEFAULT_API_URL;
return !!url && normalizeApiUrl(url) !== DEFAULT_API_URL;
}

/**
Expand Down