Skip to content
Merged
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
12 changes: 12 additions & 0 deletions .changeset/x-yvp-sdk-header.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@youversion/platform-core": minor
"@youversion/platform-react-hooks": minor
"@youversion/platform-react-ui": minor
---

Add `X-YVP-Sdk` header to every API call and let consumers override headers

- New `X-YVP-Sdk: ReactSDK={version}` header sent on every request alongside `X-YVP-App-Key`. The version is imported directly from `packages/core/package.json` and inlined by the bundler at build time.
- `SDK_VERSION`, `SDK_NAME`, and `SDK_VERSION_HEADER_NAME` exported from `@youversion/platform-core`.
- `ApiConfig` gains an optional `additionalHeaders` map that is merged into every request. Keys here override the SDK's built-in headers, so wrappers (e.g. the React Native Expo SDK) can replace `X-YVP-Sdk` with their own identifier.
- `YouVersionProvider` gains an `additionalHeaders` prop that flows through context to every hook-built `ApiClient`.
2 changes: 2 additions & 0 deletions packages/core/src/YouVersionAPI.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { YouVersionPlatformConfiguration } from './YouVersionPlatformConfiguration';
import { SDK_VERSION_HEADER_NAME, buildSdkVersionHeaderValue } from './version';

export class YouVersionAPI {
static addStandardHeaders(url: URL): Request {
const headers: Record<string, string> = {
Accept: 'application/json',
'Content-Type': 'application/json',
[SDK_VERSION_HEADER_NAME]: buildSdkVersionHeaderValue(),
};

const appKey = YouVersionPlatformConfiguration.appKey;
Expand Down
77 changes: 77 additions & 0 deletions packages/core/src/__tests__/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,83 @@ describe('ApiClient', () => {
});
});

describe('default headers', () => {
it('should send X-YVP-Sdk header with ReactSDK identifier on every request', async () => {
let receivedHeader: string | null = null;
server.use(
http.get('https://test_placeholder.youversion.com/test', ({ request }) => {
receivedHeader = request.headers.get('x-yvp-sdk');
return HttpResponse.json({});
}),
);

await apiClient.get('/test');

expect(receivedHeader).toMatch(/^ReactSDK=.+$/);
});

it('should send X-YVP-App-Key header on every request', async () => {
let receivedAppKey: string | null = null;
server.use(
http.get('https://test_placeholder.youversion.com/test', ({ request }) => {
receivedAppKey = request.headers.get('x-yvp-app-key');
return HttpResponse.json({});
}),
);

await apiClient.get('/test');

expect(receivedAppKey).toBe('test-app');
});
});

describe('additionalHeaders', () => {
it('should send caller-supplied headers in addition to the built-in ones', async () => {
const client = new ApiClient({
apiHost: 'test_placeholder.youversion.com',
appKey: 'test-app',
additionalHeaders: { 'X-Custom': 'hello' },
});

let receivedCustom: string | null = null;
let receivedAppKey: string | null = null;
server.use(
http.get('https://test_placeholder.youversion.com/test', ({ request }) => {
receivedCustom = request.headers.get('x-custom');
receivedAppKey = request.headers.get('x-yvp-app-key');
return HttpResponse.json({});
}),
);

await client.get('/test');

expect(receivedCustom).toBe('hello');
expect(receivedAppKey).toBe('test-app');
});

it('should let additionalHeaders override the built-in X-YVP-Sdk header', async () => {
// Mirrors how a React Native Expo wrapper would replace the web SDK's
// identifier with its own.
const client = new ApiClient({
apiHost: 'test_placeholder.youversion.com',
appKey: 'test-app',
additionalHeaders: { 'X-YVP-Sdk': 'ReactNativeSDK=1.2.3' },
});

let receivedSdk: string | null = null;
server.use(
http.get('https://test_placeholder.youversion.com/test', ({ request }) => {
receivedSdk = request.headers.get('x-yvp-sdk');
return HttpResponse.json({});
}),
);

await client.get('/test');

expect(receivedSdk).toBe('ReactNativeSDK=1.2.3');
});
});

describe('post', () => {
it('should make POST request and return data', async () => {
server.use(
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ApiConfig } from './types';
import { SDK_VERSION_HEADER_NAME, buildSdkVersionHeaderValue } from './version';

type PrimitiveQueryParam = string | number | boolean;
type QueryParams = Record<string, PrimitiveQueryParam | PrimitiveQueryParam[]>;
Expand Down Expand Up @@ -34,6 +35,8 @@ export class ApiClient {
'Content-Type': 'application/json',
'X-YVP-App-Key': this.config.appKey,
'X-YVP-Installation-Id': this.config.installationId || 'web-sdk-default',
[SDK_VERSION_HEADER_NAME]: buildSdkVersionHeaderValue(),
...config.additionalHeaders,
};
}

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ export {
type TransformBibleHtmlOptions,
type TransformedBibleHtml,
} from './bible-html-transformer';
export * from './version';
7 changes: 7 additions & 0 deletions packages/core/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ export interface ApiConfig {
timeout?: number;
installationId?: string;
redirectUri?: string;
/**
* Extra HTTP headers merged into every request. Values here override the
* SDK's built-in headers when keys collide — useful for wrappers (e.g. the
* React Native Expo SDK) that need to replace `X-YVP-Sdk` with their own
* identifier.
*/
additionalHeaders?: Record<string, string>;
}

export type SignInWithYouVersionPermissionValues =
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import pkg from '../package.json' with { type: 'json' };

export const SDK_VERSION = pkg.version;

export const SDK_NAME = 'ReactSDK';

export const SDK_VERSION_HEADER_NAME = 'X-YVP-Sdk';

export function buildSdkVersionHeaderValue(): string {
return `${SDK_NAME}=${SDK_VERSION}`;
}
1 change: 1 addition & 0 deletions packages/hooks/src/context/YouVersionContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ type YouVersionContextData = {
installationId?: string;
theme?: 'light' | 'dark';
authEnabled?: boolean;
additionalHeaders?: Record<string, string>;
};

export const YouVersionContext = createContext<YouVersionContextData | null>(null);
32 changes: 30 additions & 2 deletions packages/hooks/src/context/YouVersionProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client';

import type { PropsWithChildren, ReactNode } from 'react';
import { lazy, Suspense, useEffect, useState } from 'react';
import { lazy, Suspense, useEffect, useMemo, useState } from 'react';
import { YouVersionContext } from './YouVersionContext';
import { YouVersionPlatformConfiguration } from '@youversion/platform-core';

Expand All @@ -10,6 +10,13 @@ interface YouVersionProviderPropsBase {
appKey: string;
apiHost?: string;
theme?: 'light' | 'dark' | 'system';
/**
* Extra HTTP headers to add to every API call made through hooks created by
* this provider. Values here override the SDK's built-in headers when keys
* collide — useful for wrappers (e.g. the React Native Expo SDK) that need
* to replace `X-YVP-Sdk` with their own identifier.
*/
additionalHeaders?: Record<string, string>;
}

interface YouVersionProviderPropsWithAuth extends YouVersionProviderPropsBase {
Expand Down Expand Up @@ -55,9 +62,29 @@ function useResolvedTheme(theme: 'light' | 'dark' | 'system'): 'light' | 'dark'
export function YouVersionProvider(
props: PropsWithChildren<YouVersionProviderPropsWithAuth | YouVersionProviderPropsWithoutAuth>,
): React.ReactElement {
const { appKey, apiHost = 'api.youversion.com', includeAuth, theme = 'light', children } = props;
const {
appKey,
apiHost = 'api.youversion.com',
includeAuth,
theme = 'light',
additionalHeaders,
children,
} = props;
const resolvedTheme = useResolvedTheme(theme);

// Stable identity so memoized consumers (hooks that build ApiClient) don't
// rebuild when the parent re-renders with an inline object literal. Sort
// entries before serialising so key-insertion-order differences don't
// invalidate the memo for headers that are semantically identical.
const additionalHeadersKey = additionalHeaders
? JSON.stringify(Object.entries(additionalHeaders).sort(([a], [b]) => a.localeCompare(b)))
: null;
const stableAdditionalHeaders = useMemo(
() => additionalHeaders,
// eslint-disable-next-line react-hooks/exhaustive-deps
[additionalHeadersKey],
);
Comment thread
greptile-apps[bot] marked this conversation as resolved.

// Sync props to YouVersionPlatformConfiguration so any code that reads the
// static config (e.g. core's auth/PKCE flows, called from user actions) sees
// the same values. Children that read via context get the prop directly
Expand All @@ -73,6 +100,7 @@ export function YouVersionProvider(
installationId: YouVersionPlatformConfiguration.installationId,
theme: resolvedTheme,
authEnabled: !!includeAuth,
additionalHeaders: stableAdditionalHeaders,
};

if (includeAuth) {
Expand Down
24 changes: 24 additions & 0 deletions packages/hooks/src/useBibleClient.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,30 @@ describe('useBibleClient', () => {
});
});

it('should pass additionalHeaders from context into ApiClient config', () => {
const additionalHeaders = { 'X-YVP-Sdk': 'ReactNativeSDK=1.2.3' };

const wrapper = ({ children }: { children: ReactNode }) => (
<YouVersionContext.Provider
value={{
appKey: 'test-app-key',
additionalHeaders,
}}
>
{children}
</YouVersionContext.Provider>
);

renderHook(() => useBibleClient(), { wrapper });

expect(ApiClient).toHaveBeenCalledWith(
expect.objectContaining({
appKey: 'test-app-key',
additionalHeaders,
}),
);
});

it('should throw error when appKey is null', () => {
const wrapper = ({ children }: { children: ReactNode }) => (
<YouVersionContext.Provider
Expand Down
3 changes: 2 additions & 1 deletion packages/hooks/src/useBibleClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ export function useBibleClient(): BibleClient {
appKey: context.appKey,
apiHost: context.apiHost,
installationId: context.installationId,
additionalHeaders: context.additionalHeaders,
}),
);
}, [context?.apiHost, context?.appKey, context?.installationId]);
}, [context?.apiHost, context?.appKey, context?.installationId, context?.additionalHeaders]);
}
3 changes: 2 additions & 1 deletion packages/hooks/src/useHighlights.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,10 @@ export function useHighlights(
appKey: context.appKey,
apiHost: context.apiHost,
installationId: context.installationId,
additionalHeaders: context.additionalHeaders,
}),
);
}, [context?.apiHost, context?.appKey, context?.installationId]);
}, [context?.apiHost, context?.appKey, context?.installationId, context?.additionalHeaders]);

const { data, loading, error, refetch } = useApiData<Collection<Highlight>>(
() => highlightsClient.getHighlights(options),
Expand Down
3 changes: 2 additions & 1 deletion packages/hooks/src/useLanguageClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ export function useLanguagesClient(): LanguagesClient {
appKey: context.appKey,
apiHost: context.apiHost,
installationId: context.installationId,
additionalHeaders: context.additionalHeaders,
}),
);
}, [context?.apiHost, context?.appKey, context?.installationId]);
}, [context?.apiHost, context?.appKey, context?.installationId, context?.additionalHeaders]);
}
48 changes: 48 additions & 0 deletions packages/ui/src/components/YouVersionProvider.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* @vitest-environment jsdom
*/
import { describe, it, expect, vi } from 'vitest';
import { render } from '@testing-library/react';
import React from 'react';
import { YouVersionProvider } from '@/components/YouVersionProvider';

const baseProviderMock =
vi.fn<(props: Record<string, unknown> & { children?: React.ReactNode }) => React.ReactElement>();
baseProviderMock.mockImplementation(({ children }) => <>{children}</>);

vi.mock('@youversion/platform-react-hooks', () => ({
YouVersionProvider: (props: Record<string, unknown> & { children?: React.ReactNode }) =>
baseProviderMock(props),
useYVAuth: vi.fn(),
}));

describe('UI YouVersionProvider', () => {
it('forwards additionalHeaders to the underlying hooks provider', () => {
const additionalHeaders = { 'X-YVP-Sdk': 'ReactNativeSDK=1.2.3' };

render(
<YouVersionProvider appKey="test-key" additionalHeaders={additionalHeaders}>
<div data-testid="child">hello</div>
</YouVersionProvider>,
);

expect(baseProviderMock).toHaveBeenCalledWith(
expect.objectContaining({
appKey: 'test-key',
additionalHeaders,
}),
);
});

it('omits additionalHeaders when not provided', () => {
render(
<YouVersionProvider appKey="test-key">
<div data-testid="child">hello</div>
</YouVersionProvider>,
);

const lastCall = baseProviderMock.mock.calls.at(-1)?.[0] as Record<string, unknown>;
expect(lastCall?.appKey).toBe('test-key');
expect(lastCall?.additionalHeaders).toBeUndefined();
});
});
Loading