From 095b3d7e1ae00c1f6d01431fce576573a02f6de9 Mon Sep 17 00:00:00 2001 From: Nadeem Patwekar Date: Tue, 19 Aug 2025 15:12:52 +0530 Subject: [PATCH] feat: add helper functions for endpoints --- .talismanrc | 4 +- CHANGELOG.md | 3 + __test__/endpoints.test.ts | 346 +++++++++++++++++++++++++++++++++++++ package.json | 2 +- src/endpoints.ts | 97 +++++++++++ src/index.ts | 3 +- tsconfig.json | 1 + 7 files changed, 453 insertions(+), 3 deletions(-) create mode 100644 __test__/endpoints.test.ts create mode 100644 src/endpoints.ts diff --git a/.talismanrc b/.talismanrc index 2d95750..24ea68c 100644 --- a/.talismanrc +++ b/.talismanrc @@ -7,4 +7,6 @@ fileignoreconfig: - filename: src/entry-editable.ts checksum: f9c4694229205fca252bb087482a3e408c6ad3b237cd108e337bcff49458db5c - filename: .husky/pre-commit - checksum: 5baabd7d2c391648163f9371f0e5e9484f8fb90fa2284cfc378732ec3192c193 \ No newline at end of file + checksum: 5baabd7d2c391648163f9371f0e5e9484f8fb90fa2284cfc378732ec3192c193 +- filename: src/endpoints.ts + checksum: 061295893d0ef7f3be959b65b857c543a4ad8439c07a1ecea2ebb5864eb99f18 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 000f8f4..2b441f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## [1.5.0](https://github.com/contentstack/contentstack-utils-javascript/tree/v1.5.0) + - Feat: Adds Helper functions for Contentstack Endpoints + ## [1.4.1](https://github.com/contentstack/contentstack-utils-javascript/tree/v1.4.1) (2025-05-26) - Chore: Handle case sensitivity for contentType and locale diff --git a/__test__/endpoints.test.ts b/__test__/endpoints.test.ts new file mode 100644 index 0000000..e05ea62 --- /dev/null +++ b/__test__/endpoints.test.ts @@ -0,0 +1,346 @@ +import { getContentstackEndpoint, Region, ContentstackEndpoints } from '../src/endpoints'; + +// Mock fetch globally +Object.defineProperty(window, 'fetch', { + value: jest.fn(), + writable: true, +}); + +describe('getContentstackEndpoint', () => { + const mockFetch = fetch as jest.MockedFunction; + + beforeEach(() => { + mockFetch.mockClear(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + const mockEndpointsData = { + "AWS": { + "NA": { + "CDA": "https://cdn.contentstack.io", + "CMA": "https://api.contentstack.io", + "Analytics": "https://app.contentstack.com", + "GraphQL": "https://graphql.contentstack.com", + "Personalize": { + "Management": "https://personalize-api.contentstack.com", + "Edge": "https://personalize-edge.contentstack.com" + } + }, + "EU": { + "CDA": "https://eu-cdn.contentstack.com", + "CMA": "https://eu-api.contentstack.com", + "Analytics": "https://eu-app.contentstack.com", + "GraphQL": "https://eu-graphql.contentstack.com" + }, + "AU": { + "CDA": "https://au-cdn.contentstack.com", + "CMA": "https://au-api.contentstack.com" + } + }, + "AZURE": { + "NA": { + "CDA": "https://azure-na-cdn.contentstack.com", + "CMA": "https://azure-na-api.contentstack.com" + }, + "EU": { + "CDA": "https://azure-eu-cdn.contentstack.com", + "CMA": "https://azure-eu-api.contentstack.com" + } + }, + "GCP": { + "NA": { + "CDA": "https://gcp-na-cdn.contentstack.com", + "CMA": "https://gcp-na-api.contentstack.com" + }, + "EU": { + "CDA": "https://gcp-eu-cdn.contentstack.com", + "CMA": "https://gcp-eu-api.contentstack.com" + } + } + }; + + describe('successful scenarios', () => { + beforeEach(() => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers(), + redirected: false, + type: 'basic', + url: '', + clone: jest.fn(), + body: null, + bodyUsed: false, + arrayBuffer: jest.fn(), + blob: jest.fn(), + formData: jest.fn(), + text: jest.fn(), + json: async () => mockEndpointsData, + } as unknown as Response); + }); + + test('should return US region endpoints by default', async () => { + const result = await getContentstackEndpoint(); + + expect(result).toEqual(mockEndpointsData.AWS.NA); + expect(mockFetch).toHaveBeenCalledWith('https://raw.githubusercontent.com/contentstack/contentstack-endpoints/master/src/endpoints.json'); + }); + + test('should return EU region endpoints', async () => { + const result = await getContentstackEndpoint(Region.EU); + + expect(result).toEqual(mockEndpointsData.AWS.EU); + }); + + test('should return AU region endpoints', async () => { + const result = await getContentstackEndpoint(Region.AU); + + expect(result).toEqual(mockEndpointsData.AWS.AU); + }); + + test('should return Azure NA region endpoints', async () => { + const result = await getContentstackEndpoint(Region.AZURE_NA); + + expect(result).toEqual(mockEndpointsData.AZURE.NA); + }); + + test('should return Azure EU region endpoints', async () => { + const result = await getContentstackEndpoint(Region.AZURE_EU); + + expect(result).toEqual(mockEndpointsData.AZURE.EU); + }); + + test('should return GCP NA region endpoints', async () => { + const result = await getContentstackEndpoint(Region.GCP_NA); + + expect(result).toEqual(mockEndpointsData.GCP.NA); + }); + + test('should return GCP EU region endpoints', async () => { + const result = await getContentstackEndpoint(Region.GCP_EU); + + expect(result).toEqual(mockEndpointsData.GCP.EU); + }); + + test('should return endpoints with HTTPS when omitHttps is false', async () => { + const result = await getContentstackEndpoint(Region.US, false); + + expect(result.CDA).toBe('https://cdn.contentstack.io'); + expect(result.CMA).toBe('https://api.contentstack.io'); + }); + + test('should return endpoints without HTTPS when omitHttps is true', async () => { + const result = await getContentstackEndpoint(Region.US, true); + + expect(result.CDA).toBe('cdn.contentstack.io'); + expect(result.CMA).toBe('api.contentstack.io'); + expect(result.Analytics).toBe('app.contentstack.com'); + }); + + test('should handle nested objects when omitHttps is true', async () => { + const result = await getContentstackEndpoint(Region.US, true); + + expect(result.Personalize).toEqual({ + "Management": "personalize-api.contentstack.com", + "Edge": "personalize-edge.contentstack.com" + }); + }); + + test('should preserve nested objects when omitHttps is false', async () => { + const result = await getContentstackEndpoint(Region.US, false); + + expect(result.Personalize).toEqual({ + "Management": "https://personalize-api.contentstack.com", + "Edge": "https://personalize-edge.contentstack.com" + }); + }); + }); + + describe('error scenarios', () => { + test('should throw error when fetch fails with network error', async () => { + mockFetch.mockRejectedValue(new Error('Network error')); + + await expect(getContentstackEndpoint()).rejects.toThrow('Network error'); + }); + + test('should throw error when HTTP response is not ok', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + headers: new Headers(), + redirected: false, + type: 'basic', + url: '', + clone: jest.fn(), + body: null, + bodyUsed: false, + arrayBuffer: jest.fn(), + blob: jest.fn(), + formData: jest.fn(), + text: jest.fn(), + json: jest.fn(), + } as unknown as Response); + + await expect(getContentstackEndpoint()).rejects.toThrow( + 'Failed to fetch endpoints from https://raw.githubusercontent.com/contentstack/contentstack-endpoints/master/src/endpoints.json. HTTP status: 404 - Not Found' + ); + }); + + test('should throw error when JSON parsing fails', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers(), + redirected: false, + type: 'basic', + url: '', + clone: jest.fn(), + body: null, + bodyUsed: false, + arrayBuffer: jest.fn(), + blob: jest.fn(), + formData: jest.fn(), + text: jest.fn(), + json: async () => { + throw new Error('Invalid JSON'); + }, + } as unknown as Response); + + await expect(getContentstackEndpoint()).rejects.toThrow( + 'Failed to parse JSON response from https://raw.githubusercontent.com/contentstack/contentstack-endpoints/master/src/endpoints.json. Response may not be valid JSON.' + ); + }); + + test('should throw error for invalid region', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers(), + redirected: false, + type: 'basic', + url: '', + clone: jest.fn(), + body: null, + bodyUsed: false, + arrayBuffer: jest.fn(), + blob: jest.fn(), + formData: jest.fn(), + text: jest.fn(), + json: async () => mockEndpointsData, + } as unknown as Response); + + await expect(getContentstackEndpoint('invalid-region' as Region)).rejects.toThrow( + 'Invalid region: invalid-region. Supported regions are: us, eu, au, azure-na, azure-eu, gcp-na, gcp-eu' + ); + }); + + test('should throw error when region data is missing from JSON', async () => { + const incompleteData = { + "AWS": { + "NA": { + "CDA": "https://cdn.contentstack.io" + } + } + }; + + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers(), + redirected: false, + type: 'basic', + url: '', + clone: jest.fn(), + body: null, + bodyUsed: false, + arrayBuffer: jest.fn(), + blob: jest.fn(), + formData: jest.fn(), + text: jest.fn(), + json: async () => incompleteData, + } as unknown as Response); + + await expect(getContentstackEndpoint(Region.EU)).rejects.toThrow( + 'No endpoints found for region: eu (provider: AWS, region: EU)' + ); + }); + + test('should throw error when provider is missing from JSON', async () => { + const incompleteData = { + "AWS": { + "NA": { + "CDA": "https://cdn.contentstack.io" + } + } + }; + + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers(), + redirected: false, + type: 'basic', + url: '', + clone: jest.fn(), + body: null, + bodyUsed: false, + arrayBuffer: jest.fn(), + blob: jest.fn(), + formData: jest.fn(), + text: jest.fn(), + json: async () => incompleteData, + } as unknown as Response); + + await expect(getContentstackEndpoint(Region.AZURE_NA)).rejects.toThrow( + 'No endpoints found for region: azure-na (provider: AZURE, region: NA)' + ); + }); + }); + + describe('Region enum', () => { + test('should have correct region values', () => { + expect(Region.US).toBe('us'); + expect(Region.EU).toBe('eu'); + expect(Region.AU).toBe('au'); + expect(Region.AZURE_NA).toBe('azure-na'); + expect(Region.AZURE_EU).toBe('azure-eu'); + expect(Region.GCP_NA).toBe('gcp-na'); + expect(Region.GCP_EU).toBe('gcp-eu'); + }); + }); + + describe('ContentstackEndpoints interface', () => { + test('should accept string values', () => { + const endpoints: ContentstackEndpoints = { + CDA: 'https://cdn.contentstack.io', + CMA: 'https://api.contentstack.io' + }; + + expect(endpoints.CDA).toBe('https://cdn.contentstack.io'); + expect(endpoints.CMA).toBe('https://api.contentstack.io'); + }); + + test('should accept nested objects', () => { + const endpoints: ContentstackEndpoints = { + Personalize: { + Management: 'https://personalize-api.contentstack.com', + Edge: 'https://personalize-edge.contentstack.com' + } + }; + + expect(endpoints.Personalize).toEqual({ + Management: 'https://personalize-api.contentstack.com', + Edge: 'https://personalize-edge.contentstack.com' + }); + }); + }); +}); diff --git a/package.json b/package.json index c23bd12..5837b40 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@contentstack/utils", - "version": "1.4.1", + "version": "1.5.0", "description": "Contentstack utilities for Javascript", "main": "dist/index.es.js", "types": "dist/types/index.d.ts", diff --git a/src/endpoints.ts b/src/endpoints.ts new file mode 100644 index 0000000..b1b0d8c --- /dev/null +++ b/src/endpoints.ts @@ -0,0 +1,97 @@ +// Enum definition first +export enum Region { + US = "us", + EU = "eu", + AU = "au", + AZURE_NA = "azure-na", + AZURE_EU = "azure-eu", + GCP_NA = "gcp-na", + GCP_EU = "gcp-eu" +} + +// Type definitions +export interface ContentstackEndpoints { + [key: string]: string | ContentstackEndpoints; +} + +interface RegionEndpoints { + [provider: string]: { + [region: string]: ContentstackEndpoints; + }; +} + + + +// Default endpoint URL - should return the same structure as endpoints.json +const DEFAULT_ENDPOINTS_URL = 'https://raw.githubusercontent.com/contentstack/contentstack-endpoints/master/src/endpoints.json'; + +// Function to remove https prefix +function removeHttps(url: string): string { + return url.replace(/^https:\/\//, ''); +} + +// Map regions to the data structure paths +const regionToPath: { [key in Region]: string[] } = { + [Region.US]: ['AWS', 'NA'], + [Region.EU]: ['AWS', 'EU'], + [Region.AU]: ['AWS', 'AU'], + [Region.AZURE_NA]: ['AZURE', 'NA'], + [Region.AZURE_EU]: ['AZURE', 'EU'], + [Region.GCP_NA]: ['GCP', 'NA'], + [Region.GCP_EU]: ['GCP', 'EU'] +}; + +// Function to fetch endpoints from remote URL +async function fetchEndpointsData(url: string = DEFAULT_ENDPOINTS_URL): Promise { + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`Failed to fetch endpoints from ${url}. HTTP status: ${response.status} - ${response.statusText}`); + } + + try { + const endpointsData = await response.json(); + return endpointsData; + } catch (parseError) { + throw new Error(`Failed to parse JSON response from ${url}. Response may not be valid JSON.`); + } +} + +export async function getContentstackEndpoint(region: Region = Region.US, omitHttps: boolean = false): Promise { + // Fetch endpoints data from remote URL - will throw error if fails + const regionEndpoints = await fetchEndpointsData(); + + // Get the path for the specified region + const path = regionToPath[region]; + if (!path || path.length !== 2) { + throw new Error(`Invalid region: ${region}. Supported regions are: ${Object.values(Region).join(', ')}`); + } + + const [provider, regionKey] = path; + const endpoints: ContentstackEndpoints = regionEndpoints[provider]?.[regionKey]; + + if (!endpoints) { + throw new Error(`No endpoints found for region: ${region} (provider: ${provider}, region: ${regionKey})`); + } + + if (omitHttps) { + const result: ContentstackEndpoints = {}; + Object.entries(endpoints).forEach(([key, value]: [string, any]) => { + if (typeof value === 'string') { + result[key] = removeHttps(value); + } else if (typeof value === 'object' && value !== null) { + // Handle nested objects (like Personalize) + const nestedResult: { [key: string]: any } = {}; + Object.entries(value).forEach(([nestedKey, nestedValue]: [string, any]) => { + nestedResult[nestedKey] = typeof nestedValue === 'string' ? removeHttps(nestedValue) : nestedValue; + }); + result[key] = nestedResult; + } else { + result[key] = value; + } + }); + return result; + } + + return endpoints; +} diff --git a/src/index.ts b/src/index.ts index 37cf3f3..a2e7168 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,4 +13,5 @@ export { default as TextNode } from './nodes/text-node'; export { jsonToHTML } from './json-to-html' export { GQL } from './gql' export { addTags as addEditableTags } from './entry-editable' -export { updateAssetURLForGQL } from './updateAssetURLForGQL' \ No newline at end of file +export { updateAssetURLForGQL } from './updateAssetURLForGQL' +export { getContentstackEndpoint, Region, ContentstackEndpoints } from './endpoints' \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index b4421ff..6b7a90f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,6 +16,7 @@ ], "types": ["jest"], "esModuleInterop": true, + "resolveJsonModule": true, "strictNullChecks": false, "sourceMap": true, },