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
17 changes: 0 additions & 17 deletions gatsby-browser.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,15 @@
import React, { useEffect, useRef } from 'react';
import type { GatsbyBrowser } from 'gatsby';

import { reducerFlashes } from '@ably/ui/core/Flash';
import { initInsights, trackPageView, setupObserver } from '@ably/ui/core/insights';
import {
attachStoreToWindow,
createRemoteDataStore,
reducerBlogPosts,
reducerSessionData,
} from '@ably/ui/core/scripts';
import { PostHogProvider } from 'posthog-js/react';
import posthog from 'posthog-js';

import { reducerApiKeyData } from './src/redux/api-key/api-key-reducer';
import UserContextWrapper from './src/contexts/user-context/wrap-with-provider';
import { useSiteMetadata } from './src/hooks/use-site-metadata';

const onClientEntry: GatsbyBrowser['onClientEntry'] = () => {
setupObserver();

const store = createRemoteDataStore({
...reducerBlogPosts,
...reducerSessionData,
...reducerApiKeyData,
...reducerFlashes,
});

attachStoreToWindow(store);
};

/**
Expand Down
2 changes: 1 addition & 1 deletion mocks/handlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
WEB_API_USER_DATA_ENDPOINT,
WEB_API_KEYS_DATA_ENDPOINT,
WEB_API_TEMP_KEY_ENDPOINT,
} from 'src/redux/api-key/constants';
} from 'src/contexts/user-context/api-keys';

export const handlers = [
http.get(WEB_API_USER_DATA_ENDPOINT, () => {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
"validate-llms-txt": "node bin/validate-llms.txt.ts"
},
"dependencies": {
"@ably/ui": "17.14.0",
"@ably/ui": "18.3.1",
"@codesandbox/sandpack-react": "^2.20.0",
"@codesandbox/sandpack-themes": "^2.0.21",
"@gfx/zopfli": "^1.0.15",
Expand Down
2 changes: 0 additions & 2 deletions src/components/GlobalLoading/GlobalLoading.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { FC, ReactNode, useEffect, useContext, useMemo } from 'react';
import { useStaticQuery, graphql } from 'gatsby';

// Session-related scripts
import '@ably/ui/core/scripts';
import UserContext from '../../contexts/user-context';

import externalScriptInjector from 'src/external-scripts';
Expand Down
47 changes: 14 additions & 33 deletions src/contexts/user-context.test.tsx
Original file line number Diff line number Diff line change
@@ -1,46 +1,27 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { SessionDataProvider } from '@ably/ui/core/scripts';

import UserContext from './user-context';
import { UserContextWrapper } from './user-context/wrap-with-provider';
import { WEB_API_USER_DATA_ENDPOINT } from './user-context/api-keys';

import { createRemoteDataStore, attachStoreToWindow, reducerSessionData } from '@ably/ui/core/scripts';

import { reducerApiKeyData } from 'src/redux/api-key/api-key-reducer';

const onClientEntry = () => {
const store = createRemoteDataStore({
...reducerSessionData,
...reducerApiKeyData,
});

attachStoreToWindow(store);
};

/**
* This test is disabled until we can see the issue below being resolved in
* msw.
*
* https://github.com/mswjs/msw/issues/1785
*/
// eslint-disable-next-line jest/no-disabled-tests
test.skip('<UserContextWrapper />', async () => {
onClientEntry();

test('UserContextWrapper publishes session and demo api keys via UserContext', async () => {
render(
<UserContextWrapper>
<UserContext.Consumer>
{({ sessionState, apiKeys }) => {
return (
<SessionDataProvider sessionDataUrl={WEB_API_USER_DATA_ENDPOINT}>
<UserContextWrapper>
<UserContext.Consumer>
{({ sessionState, apps }) => (
<>
<div data-testid="session">{JSON.stringify(sessionState)}</div>
<div data-testid="api-keys">{JSON.stringify(apiKeys)}</div>
<div data-testid="apps">{JSON.stringify(apps)}</div>
</>
);
}}
</UserContext.Consumer>
</UserContextWrapper>,
)}
</UserContext.Consumer>
</UserContextWrapper>
</SessionDataProvider>,
);

expect(await screen.getByText('DEMO:API-KEY G')).toBeInTheDocument();
expect(await screen.findByText(/DEMO:API-KEY/)).toBeInTheDocument();
});
19 changes: 6 additions & 13 deletions src/contexts/user-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,20 +50,13 @@ export type SessionState = {
hubspot?: AblyHubspotData;
} & SessionData;

type WildcardCapability = Record<string, string[]>;

export type AppApiKey = {
ui_compatible_capabilities: boolean;
capability: WildcardCapability;
revocableTokens: boolean;
paas_linked: boolean;
is_webhook: boolean;
webhook_url: string;
whole_key: string;
created: string;
// Only the two fields the docs site actually reads. The upstream endpoint
// returns more, but the api-key fetcher narrows down to this pair before
// publishing to UserContext.
export interface AppApiKey {
name: string;
id: string;
};
whole_key: string;
}

export type App = {
name: string;
Expand Down
116 changes: 116 additions & 0 deletions src/contexts/user-context/api-keys.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { http, HttpResponse } from 'msw';

import { server } from '../../../mocks/server';
import { fetchApps, WEB_API_KEYS_DATA_ENDPOINT, WEB_API_TEMP_KEY_ENDPOINT } from './api-keys';

const APP_KEYS_URL = 'https://example.test/apps/a/keys';

beforeEach(() => {
// Stub the ad hoc window globals the demo-key flow writes to. They're not
// asserted on directly; the cleanup is so test runs stay isolated.
(window as unknown as { ably?: unknown }).ably = {
docs: {
DOCS_API_KEY: false,
randomChannelName: '',
onApiKeyRetrieved: () => undefined,
},
};
});

afterEach(() => {
delete (window as unknown as { ably?: unknown }).ably;
});

describe('fetchApps', () => {
it('returns the demo app plus any real apps when both endpoints respond', async () => {
server.use(
http.get(WEB_API_TEMP_KEY_ENDPOINT, () => HttpResponse.text('DEMO:KEY')),
http.get(WEB_API_KEYS_DATA_ENDPOINT, () => HttpResponse.json({ data: [{ name: 'My App', url: APP_KEYS_URL }] })),
http.get(APP_KEYS_URL, () => HttpResponse.json([{ name: 'Root', whole_key: 'REAL:KEY' }])),
);

const apps = await fetchApps();

expect(apps).toHaveLength(2);
expect(apps[0]).toMatchObject({ demo: true, apiKeys: [{ whole_key: 'DEMO:KEY' }] });
expect(apps[1]).toMatchObject({
demo: false,
name: 'My App',
url: APP_KEYS_URL,
apiKeys: [{ name: 'Root', whole_key: 'REAL:KEY' }],
});
});

it('still returns real apps when the demo-key endpoint is down', async () => {
server.use(
http.get(WEB_API_TEMP_KEY_ENDPOINT, () => new HttpResponse(null, { status: 503 })),
http.get(WEB_API_KEYS_DATA_ENDPOINT, () => HttpResponse.json({ data: [{ name: 'My App', url: APP_KEYS_URL }] })),
http.get(APP_KEYS_URL, () => HttpResponse.json([{ name: 'Root', whole_key: 'REAL:KEY' }])),
);

const apps = await fetchApps();

expect(apps).toHaveLength(1);
expect(apps[0]).toMatchObject({ demo: false, apiKeys: [{ whole_key: 'REAL:KEY' }] });
});

it('returns the demo app alone when the api-keys endpoint fails', async () => {
server.use(
http.get(WEB_API_TEMP_KEY_ENDPOINT, () => HttpResponse.text('DEMO:KEY')),
http.get(WEB_API_KEYS_DATA_ENDPOINT, () => new HttpResponse(null, { status: 500 })),
);

const apps = await fetchApps();

expect(apps).toEqual([
expect.objectContaining({ demo: true, apiKeys: [expect.objectContaining({ whole_key: 'DEMO:KEY' })] }),
]);
});

it('returns just the demo app when api-keys payload signals "not-found"', async () => {
server.use(
http.get(WEB_API_TEMP_KEY_ENDPOINT, () => HttpResponse.text('DEMO:KEY')),
http.get(WEB_API_KEYS_DATA_ENDPOINT, () => HttpResponse.json({ error: 'not-found' })),
);

const apps = await fetchApps();

expect(apps).toEqual([
expect.objectContaining({ demo: true, apiKeys: [expect.objectContaining({ whole_key: 'DEMO:KEY' })] }),
]);
});

it('returns an empty list when both endpoints fail', async () => {
server.use(
http.get(WEB_API_TEMP_KEY_ENDPOINT, () => new HttpResponse(null, { status: 500 })),
http.get(WEB_API_KEYS_DATA_ENDPOINT, () => new HttpResponse(null, { status: 500 })),
);

const apps = await fetchApps();

expect(apps).toEqual([]);
});

it('drops individual real-app entries whose key URL fails', async () => {
const FAILING_URL = 'https://example.test/apps/broken/keys';

server.use(
http.get(WEB_API_TEMP_KEY_ENDPOINT, () => HttpResponse.text('DEMO:KEY')),
http.get(WEB_API_KEYS_DATA_ENDPOINT, () =>
HttpResponse.json({
data: [
{ name: 'Working', url: APP_KEYS_URL },
{ name: 'Broken', url: FAILING_URL },
],
}),
),
http.get(APP_KEYS_URL, () => HttpResponse.json([{ name: 'Root', whole_key: 'REAL:KEY' }])),
http.get(FAILING_URL, () => new HttpResponse(null, { status: 404 })),
);

const apps = await fetchApps();

expect(apps).toHaveLength(2); // demo + working only
expect(apps.map((app) => app.name)).toEqual(['Demo Only', 'Working']);
});
});
117 changes: 117 additions & 0 deletions src/contexts/user-context/api-keys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import type { App, AppApiKey } from '../user-context';

const WEB_API = process.env.GATSBY_ABLY_MAIN_WEBSITE || 'http://localhost:3000';

export const WEB_API_USER_DATA_ENDPOINT = `${WEB_API}/api/me`;
export const WEB_API_KEYS_DATA_ENDPOINT = `${WEB_API}/api/api_keys`;
export const WEB_API_TEMP_KEY_ENDPOINT = `${WEB_API}/ably-auth/api-key/docs`;

const FETCH_OPTIONS: RequestInit = { cache: 'no-cache' };

declare global {
interface Window {
ably: {
docs: {
DOCS_API_KEY: boolean | string;
randomChannelName: string;
onApiKeyRetrieved: () => void;
};
};
}
}

interface ApiKeyValue {
name: string;
url: string;
}

interface ApiKeysPayload {
data?: ApiKeyValue[];
error?: unknown;
}

const safelyInvokeApiKeyRetrievalTrigger = () => {
try {
window.ably.docs.onApiKeyRetrieved();
} catch (e) {
console.error(e);
}
};

const fetchJson = async <T>(url: string, label: string): Promise<T> => {
const res = await fetch(url, FETCH_OPTIONS);
if (!res.ok) {
throw new Error(`${label} endpoint at ${url} returned HTTP ${res.status}`);
}
const contentType = res.headers.get('content-type');
if (!contentType?.includes('application/json')) {
const text = await res.text();
throw new Error(`${label} endpoint at ${url} is not serving JSON, received:\n\n${text}`);
}
return res.json() as Promise<T>;
};

const fetchDemoApp = async (): Promise<App> => {
const res = await fetch(WEB_API_TEMP_KEY_ENDPOINT, FETCH_OPTIONS);
if (!res.ok) {
throw new Error(`temp-key endpoint at ${WEB_API_TEMP_KEY_ENDPOINT} returned HTTP ${res.status}`);
}
const tempApiKey = await res.text();

if (window.ably?.docs && !window.ably.docs.DOCS_API_KEY) {
window.ably.docs.DOCS_API_KEY = tempApiKey;
safelyInvokeApiKeyRetrievalTrigger();
}

return {
name: 'Demo Only',
demo: true,
url: WEB_API_TEMP_KEY_ENDPOINT,
apiKeys: [{ name: 'Demo Only', whole_key: tempApiKey }],
};
Comment thread
kennethkalmer marked this conversation as resolved.
};

export const fetchApps = async (): Promise<App[]> => {
// Best-effort: a temp-key outage shouldn't block the signed-in api-key list.
let demoApp: App | null = null;
try {
demoApp = await fetchDemoApp();
} catch (e) {
console.warn('Could not fetch demo api key:', e);
}

let payload: ApiKeysPayload;
try {
payload = await fetchJson<ApiKeysPayload>(WEB_API_KEYS_DATA_ENDPOINT, 'api-keys');
} catch (e) {
console.warn('Could not fetch api keys due to error:', e);
return demoApp ? [demoApp] : [];
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (payload.error || !Array.isArray(payload.data)) {
return demoApp ? [demoApp] : [];
}

const realApps = await Promise.all(
payload.data.map(async (entry): Promise<App | null> => {
try {
const apiKeysRaw = await fetchJson<AppApiKey[]>(entry.url, 'api-key-retrieval');
const apiKeys: AppApiKey[] = apiKeysRaw.map(({ name, whole_key }) => ({ name, whole_key }));
return { ...entry, apiKeys, demo: false };
} catch (e) {
console.warn(`Could not fetch api keys for ${entry.url}:`, e);
return null;
}
}),
);

const apps = realApps.filter((app): app is App => app !== null);

// Supporting ad hoc scripts; remove when those scripts are.
if (window.ably?.docs && apps[0]?.apiKeys[0]?.whole_key) {
window.ably.docs.DOCS_API_KEY = apps[0].apiKeys[0].whole_key;
safelyInvokeApiKeyRetrievalTrigger();
}

return demoApp ? [demoApp, ...apps] : apps;
};
Loading