diff --git a/.changeset/x-yvp-sdk-header.md b/.changeset/x-yvp-sdk-header.md new file mode 100644 index 00000000..d8634597 --- /dev/null +++ b/.changeset/x-yvp-sdk-header.md @@ -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`. diff --git a/packages/core/src/YouVersionAPI.ts b/packages/core/src/YouVersionAPI.ts index 8093221b..2e271453 100644 --- a/packages/core/src/YouVersionAPI.ts +++ b/packages/core/src/YouVersionAPI.ts @@ -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 = { Accept: 'application/json', 'Content-Type': 'application/json', + [SDK_VERSION_HEADER_NAME]: buildSdkVersionHeaderValue(), }; const appKey = YouVersionPlatformConfiguration.appKey; diff --git a/packages/core/src/__tests__/client.test.ts b/packages/core/src/__tests__/client.test.ts index 2df047c9..3d6090f6 100644 --- a/packages/core/src/__tests__/client.test.ts +++ b/packages/core/src/__tests__/client.test.ts @@ -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( diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index c33b3249..9f0091bd 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -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; @@ -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, }; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9a04f008..05374090 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -20,3 +20,4 @@ export { type TransformBibleHtmlOptions, type TransformedBibleHtml, } from './bible-html-transformer'; +export * from './version'; diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 6b523599..792bafb4 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -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; } export type SignInWithYouVersionPermissionValues = diff --git a/packages/core/src/version.ts b/packages/core/src/version.ts new file mode 100644 index 00000000..a731e36c --- /dev/null +++ b/packages/core/src/version.ts @@ -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}`; +} diff --git a/packages/hooks/src/context/YouVersionContext.tsx b/packages/hooks/src/context/YouVersionContext.tsx index 9bdae246..ba8f418a 100644 --- a/packages/hooks/src/context/YouVersionContext.tsx +++ b/packages/hooks/src/context/YouVersionContext.tsx @@ -8,6 +8,7 @@ type YouVersionContextData = { installationId?: string; theme?: 'light' | 'dark'; authEnabled?: boolean; + additionalHeaders?: Record; }; export const YouVersionContext = createContext(null); diff --git a/packages/hooks/src/context/YouVersionProvider.tsx b/packages/hooks/src/context/YouVersionProvider.tsx index 3e15baa5..56bc243c 100644 --- a/packages/hooks/src/context/YouVersionProvider.tsx +++ b/packages/hooks/src/context/YouVersionProvider.tsx @@ -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'; @@ -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; } interface YouVersionProviderPropsWithAuth extends YouVersionProviderPropsBase { @@ -55,9 +62,29 @@ function useResolvedTheme(theme: 'light' | 'dark' | 'system'): 'light' | 'dark' export function YouVersionProvider( props: PropsWithChildren, ): 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], + ); + // 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 @@ -73,6 +100,7 @@ export function YouVersionProvider( installationId: YouVersionPlatformConfiguration.installationId, theme: resolvedTheme, authEnabled: !!includeAuth, + additionalHeaders: stableAdditionalHeaders, }; if (includeAuth) { diff --git a/packages/hooks/src/useBibleClient.test.tsx b/packages/hooks/src/useBibleClient.test.tsx index 764b5738..9489ee34 100644 --- a/packages/hooks/src/useBibleClient.test.tsx +++ b/packages/hooks/src/useBibleClient.test.tsx @@ -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 }) => ( + + {children} + + ); + + 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 }) => ( >( () => highlightsClient.getHighlights(options), diff --git a/packages/hooks/src/useLanguageClient.ts b/packages/hooks/src/useLanguageClient.ts index fa740769..4fa6bd25 100644 --- a/packages/hooks/src/useLanguageClient.ts +++ b/packages/hooks/src/useLanguageClient.ts @@ -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]); } diff --git a/packages/ui/src/components/YouVersionProvider.test.tsx b/packages/ui/src/components/YouVersionProvider.test.tsx new file mode 100644 index 00000000..69ecc7ff --- /dev/null +++ b/packages/ui/src/components/YouVersionProvider.test.tsx @@ -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 & { children?: React.ReactNode }) => React.ReactElement>(); +baseProviderMock.mockImplementation(({ children }) => <>{children}); + +vi.mock('@youversion/platform-react-hooks', () => ({ + YouVersionProvider: (props: Record & { 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( + +
hello
+
, + ); + + expect(baseProviderMock).toHaveBeenCalledWith( + expect.objectContaining({ + appKey: 'test-key', + additionalHeaders, + }), + ); + }); + + it('omits additionalHeaders when not provided', () => { + render( + +
hello
+
, + ); + + const lastCall = baseProviderMock.mock.calls.at(-1)?.[0] as Record; + expect(lastCall?.appKey).toBe('test-key'); + expect(lastCall?.additionalHeaders).toBeUndefined(); + }); +});