diff --git a/.env.example b/.env.example index f763bf0e..836df567 100644 --- a/.env.example +++ b/.env.example @@ -68,6 +68,13 @@ AQUIFER_API_URL=https://api.aquifer.bible # Request a key at https://www.aquifer.bible/apiaccess AQUIFER_API_KEY= +# ── YouVersion (Bible text for the Reference column) ───────────────────────── +# Base URL of the YouVersion API (no trailing slash). Defaults to production. +YOUVERSION_API_URL=https://api.youversion.com/v1 +# Server-held YouVersion API key — never expose to mobile/web clients. +# Optional: leave blank to boot without YouVersion; Bible text routes return 502. +YOUVERSION_API_KEY= + # ── API.Bible (DBL) Integration ────────────────────────────────────────────── # Base URL for the API.Bible endpoint. Defaults to v1. DBL_API_BASE_URL=https://rest.api.bible/v1 diff --git a/.env.test b/.env.test index 32eefa12..63b1c5eb 100644 --- a/.env.test +++ b/.env.test @@ -16,5 +16,7 @@ FLUENT_AI_KEY=test-only-dummy-fluent-ai-key AI_INBOUND_SERVICE_KEY=test-only-dummy-inbound-key AQUIFER_API_URL=https://api.aquifer.bible AQUIFER_API_KEY=test-only-dummy-aquifer-api-key +YOUVERSION_API_URL=https://api.youversion.com/v1 +YOUVERSION_API_KEY=test-only-dummy-youversion-api-key DBL_API_KEY=test-key DBL_API_TIMEOUT_MS=30000 \ No newline at end of file diff --git a/src/app.ts b/src/app.ts index e67ebdff..4fbe0d2a 100644 --- a/src/app.ts +++ b/src/app.ts @@ -33,6 +33,7 @@ import '@/domains/translation-resources/translation-resources.route'; import '@/domains/source-audio/source-audio.route'; import '@/domains/self/settings/self-settings.route'; import '@/domains/aquifer-resources/aquifer-resources.route'; +import '@/domains/youversion/youversion.route'; configureOpenAPI(server); export default server; diff --git a/src/domains/youversion/youversion.route.test.ts b/src/domains/youversion/youversion.route.test.ts new file mode 100644 index 00000000..a5a2e96e --- /dev/null +++ b/src/domains/youversion/youversion.route.test.ts @@ -0,0 +1,195 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { findGrantsByUserId } from '@/domains/user-roles/user-roles.repository'; +import { getUserByEmail } from '@/domains/users/users.service'; +import { auth } from '@/lib/auth'; +import { PERMISSIONS } from '@/lib/permissions'; +import * as youVersionClient from '@/lib/services/youversion/youversion.client'; +import { err, ErrorCode, ok } from '@/lib/types'; +import { server } from '@/server/server'; +import '@/domains/youversion/youversion.route'; + +vi.mock('@/lib/auth', () => ({ + auth: { + api: { getSession: vi.fn() }, + handler: vi.fn(), + }, +})); + +vi.mock('@/db', () => { + const mockQueryBuilder = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([{ activeOrgId: 1 }]), + }; + return { + db: { select: vi.fn(() => mockQueryBuilder), insert: vi.fn(), update: vi.fn() }, + }; +}); + +vi.mock('@/lib/logger', () => ({ + logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() }, +})); + +vi.mock('@/domains/users/users.service', () => ({ + getUserByEmail: vi.fn(), +})); + +vi.mock('@/domains/user-roles/user-roles.repository', () => ({ + findGrantsByUserId: vi.fn(), +})); + +vi.mock('@/lib/services/youversion/youversion.client', () => ({ + getBibles: vi.fn(), + getChapterText: vi.fn(), + isYouVersionConfigured: vi.fn(), +})); + +const MOCK_USER = { + id: 1, + email: 'test@example.com', + role: 5, + roleName: 'Translator', + organization: 1, + status: 'verified' as const, +}; + +function authenticateUserMock(hasPermission = true) { + vi.mocked(auth.api.getSession as any).mockResolvedValue({ + session: { id: 's1', updatedAt: new Date(), expiresAt: new Date(Date.now() + 1e9) }, + user: { email: MOCK_USER.email }, + }); + vi.mocked(getUserByEmail as any).mockResolvedValue(ok(MOCK_USER)); + vi.mocked(findGrantsByUserId as any).mockResolvedValue( + ok( + hasPermission + ? [{ orgId: 1, projectId: 1, permissions: new Set([PERMISSIONS.CONTENT_VIEW]) }] + : [] + ) + ); +} + +describe('youversion routes', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ─── Auth gates ───────────────────────────────────────────────────────────── + + describe('auth gates', () => { + it('returns 401 when unauthenticated', async () => { + vi.mocked(auth.api.getSession as any).mockResolvedValue(null); + + const res = await server.request('/youversion/bibles?language_tag=eng'); + + expect(res.status).toBe(401); + expect(youVersionClient.getBibles).not.toHaveBeenCalled(); + }); + + it('returns 403 when user lacks CONTENT_VIEW permission', async () => { + authenticateUserMock(false); + + const res = await server.request('/youversion/bibles?language_tag=eng'); + + expect(res.status).toBe(403); + expect(youVersionClient.getBibles).not.toHaveBeenCalled(); + }); + }); + + // ─── GET /youversion/bibles ────────────────────────────────────────────────── + + describe('get /youversion/bibles', () => { + it('returns bibles list and sets Cache-Control header', async () => { + authenticateUserMock(); + const mockBibles = [ + { + id: 1, + abbreviation: 'NIV', + localized_abbreviation: 'NIV', + title: 'New International Version', + localized_title: 'New International Version', + language_tag: 'eng', + }, + ]; + vi.mocked(youVersionClient.getBibles).mockResolvedValue(ok(mockBibles)); + + const res = await server.request('/youversion/bibles?language_tag=eng'); + + expect(res.status).toBe(200); + expect(res.headers.get('Cache-Control')).toBe('private, max-age=300'); + expect(youVersionClient.getBibles).toHaveBeenCalledWith('eng'); + const data = await res.json(); + expect(data).toEqual(mockBibles); + }); + + it('returns 400 when language_tag query param is missing', async () => { + authenticateUserMock(); + + const res = await server.request('/youversion/bibles'); + + expect(res.status).toBe(400); + expect(youVersionClient.getBibles).not.toHaveBeenCalled(); + }); + + it('returns 502 Bad Gateway on YouVersion failure', async () => { + authenticateUserMock(); + vi.mocked(youVersionClient.getBibles).mockResolvedValue( + err(ErrorCode.YOUVERSION_SERVICE_UNAVAILABLE) + ); + + const res = await server.request('/youversion/bibles?language_tag=eng'); + + expect(res.status).toBe(502); + const data = await res.json(); + expect(data).toHaveProperty('message'); + }); + }); + + // ─── GET /youversion/bibles/{bibleId}/chapters/{chapterId}/text ────────────── + + describe('get /youversion/bibles/{bibleId}/books/{bookId}/chapters/{chapterId}/text', () => { + it('returns chapter text and sets Cache-Control header', async () => { + authenticateUserMock(); + const mockChapterText = { + bibleId: 1, + bookId: 'GEN', + chapterId: 1, + verses: [ + { verseNumber: 1, passageId: 'GEN.1.1', content: 'In the beginning...' }, + { verseNumber: 2, passageId: 'GEN.1.2', content: 'Now the earth was...' }, + ], + }; + vi.mocked(youVersionClient.getChapterText).mockResolvedValue(ok(mockChapterText)); + + const res = await server.request('/youversion/bibles/1/books/GEN/chapters/1/text'); + + expect(res.status).toBe(200); + expect(res.headers.get('Cache-Control')).toBe('private, max-age=300'); + expect(youVersionClient.getChapterText).toHaveBeenCalledWith(1, 'GEN', 1); + const data = await res.json(); + expect(data).toEqual(mockChapterText); + }); + + it('returns 404 when bookId path segment is absent', async () => { + authenticateUserMock(); + + const res = await server.request('/youversion/bibles/1/chapters/1/text'); + + expect(res.status).toBe(404); + expect(youVersionClient.getChapterText).not.toHaveBeenCalled(); + }); + + it('returns 502 Bad Gateway on YouVersion failure', async () => { + authenticateUserMock(); + vi.mocked(youVersionClient.getChapterText).mockResolvedValue( + err(ErrorCode.YOUVERSION_SERVICE_UNAVAILABLE) + ); + + const res = await server.request('/youversion/bibles/1/books/GEN/chapters/1/text'); + + expect(res.status).toBe(502); + const data = await res.json(); + expect(data).toHaveProperty('message'); + }); + }); +}); diff --git a/src/domains/youversion/youversion.route.ts b/src/domains/youversion/youversion.route.ts new file mode 100644 index 00000000..e08cdf1b --- /dev/null +++ b/src/domains/youversion/youversion.route.ts @@ -0,0 +1,105 @@ +import { createRoute, z } from '@hono/zod-openapi'; +import * as HttpStatusCodes from 'stoker/http-status-codes'; +import * as HttpStatusPhrases from 'stoker/http-status-phrases'; +import { jsonContent } from 'stoker/openapi/helpers'; +import { createMessageObjectSchema } from 'stoker/openapi/schemas'; + +import { PERMISSIONS } from '@/lib/permissions'; +import { youVersionErrorResponse } from '@/lib/services/youversion/youversion.errors'; +import { authenticateUser, requirePermission } from '@/middlewares/role-auth'; +import { server } from '@/server/server'; + +import * as youVersionService from './youversion.service'; +import { + biblesQuerySchema, + chapterTextParamSchema, + youVersionBibleSchema, + youVersionChapterTextSchema, +} from './youversion.types'; + +const badRequestResponse = jsonContent( + createMessageObjectSchema('Bad Request'), + 'Invalid request parameters' +); +const unauthorizedResponse = jsonContent( + createMessageObjectSchema('Unauthorized'), + 'Authentication required' +); +const forbiddenResponse = jsonContent( + createMessageObjectSchema('Forbidden'), + 'Insufficient permissions' +); +const badGatewayResponse = jsonContent( + createMessageObjectSchema('YouVersion service is unavailable'), + 'Upstream YouVersion failure' +); +const internalErrorResponse = jsonContent( + createMessageObjectSchema(HttpStatusPhrases.INTERNAL_SERVER_ERROR), + 'Internal server error' +); + +// ─── GET /youversion/bibles ─────────────────────────────────────────────────── + +const getBiblesRoute = createRoute({ + tags: ['YouVersion'], + method: 'get', + path: '/youversion/bibles', + middleware: [authenticateUser, requirePermission(PERMISSIONS.CONTENT_VIEW)] as const, + request: { + query: biblesQuerySchema, + }, + responses: { + [HttpStatusCodes.OK]: jsonContent( + z.array(youVersionBibleSchema), + 'List of YouVersion Bibles for the requested language' + ), + [HttpStatusCodes.BAD_REQUEST]: badRequestResponse, + [HttpStatusCodes.UNAUTHORIZED]: unauthorizedResponse, + [HttpStatusCodes.FORBIDDEN]: forbiddenResponse, + [HttpStatusCodes.BAD_GATEWAY]: badGatewayResponse, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalErrorResponse, + }, +}); + +server.openapi(getBiblesRoute, async (c) => { + const { language_tag } = c.req.valid('query'); + const result = await youVersionService.getBibles(language_tag); + if (!result.ok) { + return youVersionErrorResponse(c, result.error); + } + c.header('Cache-Control', 'private, max-age=300'); + return c.json(result.data, HttpStatusCodes.OK); +}); + +// ─── GET /youversion/bibles/{bibleId}/books/{bookId}/chapters/{chapterId}/text ─────── + +const getChapterTextRoute = createRoute({ + tags: ['YouVersion'], + method: 'get', + path: '/youversion/bibles/{bibleId}/books/{bookId}/chapters/{chapterId}/text', + middleware: [authenticateUser, requirePermission(PERMISSIONS.CONTENT_VIEW)] as const, + request: { + params: chapterTextParamSchema, + }, + responses: { + [HttpStatusCodes.OK]: jsonContent( + youVersionChapterTextSchema, + 'All verse texts for the requested chapter (server-side fan-out)' + ), + [HttpStatusCodes.BAD_REQUEST]: badRequestResponse, + [HttpStatusCodes.UNAUTHORIZED]: unauthorizedResponse, + [HttpStatusCodes.FORBIDDEN]: forbiddenResponse, + [HttpStatusCodes.BAD_GATEWAY]: badGatewayResponse, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalErrorResponse, + }, +}); + +server.openapi(getChapterTextRoute, async (c) => { + const { bibleId, bookId, chapterId } = c.req.valid('param'); + const result = await youVersionService.getChapterText(bibleId, bookId, chapterId); + if (!result.ok) { + return youVersionErrorResponse(c, result.error); + } + c.header('Cache-Control', 'private, max-age=300'); + return c.json(result.data, HttpStatusCodes.OK); +}); diff --git a/src/domains/youversion/youversion.service.ts b/src/domains/youversion/youversion.service.ts new file mode 100644 index 00000000..6e717f5a --- /dev/null +++ b/src/domains/youversion/youversion.service.ts @@ -0,0 +1,19 @@ +import type { + YouVersionBible, + YouVersionChapterText, +} from '@/lib/services/youversion/youversion.types'; +import type { Result } from '@/lib/types'; + +import * as youVersionClient from '@/lib/services/youversion/youversion.client'; + +export async function getBibles(languageTag: string): Promise> { + return youVersionClient.getBibles(languageTag); +} + +export async function getChapterText( + bibleId: number, + bookId: string, + chapterId: number +): Promise> { + return youVersionClient.getChapterText(bibleId, bookId, chapterId); +} diff --git a/src/domains/youversion/youversion.types.ts b/src/domains/youversion/youversion.types.ts new file mode 100644 index 00000000..4f0e51db --- /dev/null +++ b/src/domains/youversion/youversion.types.ts @@ -0,0 +1,37 @@ +import { z } from '@hono/zod-openapi'; + +export { + youVersionBibleSchema, + youVersionBibleVerseSchema, + youVersionChapterTextSchema, +} from '@/lib/services/youversion/youversion.types'; + +// ─── Route-level query/param schemas ───────────────────────────────────────── + +export const biblesQuerySchema = z.object({ + language_tag: z + .string() + .min(1) + .openapi({ description: 'BCP-47 language tag (e.g. eng, fra)', example: 'eng' }), +}); + +export type BiblesQuery = z.infer; + +export const chapterTextParamSchema = z.object({ + bibleId: z.coerce + .number() + .int() + .positive() + .openapi({ description: 'YouVersion Bible ID', example: 1 }), + bookId: z + .string() + .min(1) + .openapi({ description: 'Book code matching YouVersion book ID (e.g. GEN)', example: 'GEN' }), + chapterId: z.coerce + .number() + .int() + .positive() + .openapi({ description: 'Chapter number', example: 1 }), +}); + +export type ChapterTextParam = z.infer; diff --git a/src/env.ts b/src/env.ts index c8ab311a..2d44f4e2 100644 --- a/src/env.ts +++ b/src/env.ts @@ -154,6 +154,21 @@ const EnvBaseSchema = z.object({ return trimmed === '' ? undefined : trimmed; }), + // ── YouVersion (Bible text for the Reference column) ──────────────────────── + // Base URL of the YouVersion API (no trailing slash). Defaults to production. + YOUVERSION_API_URL: z.string().url().default('https://api.youversion.com/v1'), + // Server-held YouVersion API key — never expose to mobile/web clients. + // Same degrade-don't-crash pattern as AQUIFER_API_KEY: unset/blank boots fine; + // YouVersion routes return YOUVERSION_SERVICE_UNAVAILABLE (502) until configured. + YOUVERSION_API_KEY: z + .string() + .optional() + .transform((value) => { + if (value === undefined) return undefined; + const trimmed = value.trim(); + return trimmed === '' ? undefined : trimmed; + }), + // ── API.Bible (DBL) Integration ────────────────────────────────────── DBL_API_BASE_URL: z.string().url().default('https://rest.api.bible/v1'), DBL_API_KEY: z.string().optional().default(''), diff --git a/src/lib/services/youversion/youversion.client.test.ts b/src/lib/services/youversion/youversion.client.test.ts new file mode 100644 index 00000000..33fa49ce --- /dev/null +++ b/src/lib/services/youversion/youversion.client.test.ts @@ -0,0 +1,530 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import env from '@/env'; +import { ErrorCode } from '@/lib/types'; + +import { + getBibles, + getChapterMeta, + getChapterText, + getPassage, + isYouVersionConfigured, +} from './youversion.client'; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(typeof body === 'string' ? body : JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +const mockBible = { + id: 1, + abbreviation: 'NIV', + localized_abbreviation: 'NIV', + title: 'New International Version', + localized_title: 'New International Version', + language_tag: 'eng', +}; + +const mockBiblesResponseBody = { + data: [mockBible], +}; + +const mockChapterMetaResponseBody = { + id: 101, + passage_id: 'GEN.1', + verses: [ + { id: 1, passage_id: 'GEN.1.1', human_reference: 'Genesis 1:1', usfm: ['v 1'] }, + { id: 2, passage_id: 'GEN.1.2', human_reference: 'Genesis 1:2', usfm: ['v 2'] }, + ], +}; + +const mockPassage1Body = { + id: 'GEN.1.1', + passage_id: 'GEN.1.1', + content: 'In the beginning God created the heavens and the earth.', +}; + +const mockPassage2Body = { + id: 'GEN.1.2', + passage_id: 'GEN.1.2', + content: 'Now the earth was formless and empty.', +}; + +describe('youversion.client', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('isYouVersionConfigured', () => { + it('returns true when YOUVERSION_API_KEY is populated', () => { + expect(isYouVersionConfigured()).toBe(true); + }); + + it('returns false when YOUVERSION_API_KEY is empty', () => { + const original = env.YOUVERSION_API_KEY; + env.YOUVERSION_API_KEY = ''; + try { + expect(isYouVersionConfigured()).toBe(false); + } finally { + env.YOUVERSION_API_KEY = original; + } + }); + }); + + describe('getBibles', () => { + it('returns Result.ok with bibles list on success', async () => { + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(jsonResponse(mockBiblesResponseBody)); + + const result = await getBibles('eng'); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data).toHaveLength(1); + expect(result.data[0]?.id).toBe(1); + expect(result.data[0]?.abbreviation).toBe('NIV'); + } + + expect(fetchSpy).toHaveBeenCalledOnce(); + const [url, init] = fetchSpy.mock.calls[0]!; + expect(String(url)).toContain(`${env.YOUVERSION_API_URL}/bibles`); + expect(String(url)).toContain('language_tag=eng'); + expect(init).toMatchObject({ + method: 'GET', + headers: expect.objectContaining({ 'x-yvp-app-key': env.YOUVERSION_API_KEY }), + }); + }); + + it('returns error when YOUVERSION_API_KEY is not configured', async () => { + const originalKey = env.YOUVERSION_API_KEY; + env.YOUVERSION_API_KEY = ''; + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + try { + const result = await getBibles('eng'); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe(ErrorCode.YOUVERSION_SERVICE_UNAVAILABLE); + expect(result.error.message).toContain('not configured'); + } + expect(fetchSpy).not.toHaveBeenCalled(); + } finally { + env.YOUVERSION_API_KEY = originalKey; + } + }); + + it('rejects non-HTTPS base URL before sending the API key', async () => { + const originalUrl = env.YOUVERSION_API_URL; + env.YOUVERSION_API_URL = 'http://youversion.example.test'; + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + try { + const result = await getBibles('eng'); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe(ErrorCode.YOUVERSION_SERVICE_UNAVAILABLE); + expect(result.error.message).toContain('must use HTTPS'); + } + expect(fetchSpy).not.toHaveBeenCalled(); + } finally { + env.YOUVERSION_API_URL = originalUrl; + } + }); + + it('maps non-2xx response to YOUVERSION_SERVICE_UNAVAILABLE', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + jsonResponse({ message: 'invalid key' }, 401) + ); + + const result = await getBibles('eng'); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe(ErrorCode.YOUVERSION_SERVICE_UNAVAILABLE); + expect(result.error.message).toContain('HTTP 401'); + expect(result.error.message).toContain('invalid key'); + } + }); + + it('redacts credentials echoed back in upstream error body', async () => { + const leakyBody = JSON.stringify({ + message: 'rejected', + 'x-yvp-app-key': 'secret-key-12345', + echoedKey: env.YOUVERSION_API_KEY, + }); + vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse(leakyBody, 500)); + + const result = await getBibles('eng'); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).not.toContain('secret-key-12345'); + expect(result.error.message).not.toContain(env.YOUVERSION_API_KEY); + expect(result.error.message).toContain('[redacted]'); + expect(result.error.message).toContain('HTTP 500'); + } + }); + + it('maps network failure to YOUVERSION_SERVICE_UNAVAILABLE', async () => { + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('ECONNREFUSED')); + + const result = await getBibles('eng'); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe(ErrorCode.YOUVERSION_SERVICE_UNAVAILABLE); + expect(result.error.message).toContain('ECONNREFUSED'); + } + }); + + it('maps malformed JSON response to YOUVERSION_SERVICE_UNAVAILABLE', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse('not-valid-json{')); + + const result = await getBibles('eng'); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe(ErrorCode.YOUVERSION_SERVICE_UNAVAILABLE); + expect(result.error.message).toContain('not valid JSON'); + } + }); + + it('maps schema validation failure to YOUVERSION_SERVICE_UNAVAILABLE', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + jsonResponse({ data: 'not-an-array-of-bibles' }) + ); + + const result = await getBibles('eng'); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe(ErrorCode.YOUVERSION_SERVICE_UNAVAILABLE); + expect(result.error.message).toContain('schema validation'); + } + }); + }); + + describe('getChapterMeta', () => { + it('returns chapter metadata with verse passage IDs', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse(mockChapterMetaResponseBody)); + + const result = await getChapterMeta(1, 'GEN', 1); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.id).toBe(101); + expect(result.data.verses).toHaveLength(2); + expect(result.data.verses[0]?.passage_id).toBe('GEN.1.1'); + } + }); + }); + + describe('getPassage', () => { + it('returns verse passage text content', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse(mockPassage1Body)); + + const result = await getPassage(1, 'GEN.1.1'); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.id).toBe('GEN.1.1'); + expect(result.data.content).toBe('In the beginning God created the heavens and the earth.'); + } + }); + }); + + describe('getChapterText', () => { + it('fetches chapter meta then fans out passage fetches for all verses', async () => { + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(jsonResponse(mockChapterMetaResponseBody)) + .mockResolvedValueOnce(jsonResponse(mockPassage1Body)) + .mockResolvedValueOnce(jsonResponse(mockPassage2Body)); + + const result = await getChapterText(1, 'GEN', 1); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.bibleId).toBe(1); + expect(result.data.bookId).toBe('GEN'); + expect(result.data.chapterId).toBe(1); + expect(result.data.verses).toEqual([ + { + verseNumber: 1, + passageId: 'GEN.1.1', + content: 'In the beginning God created the heavens and the earth.', + }, + { + verseNumber: 2, + passageId: 'GEN.1.2', + content: 'Now the earth was formless and empty.', + }, + ]); + } + expect(globalThis.fetch).toHaveBeenCalledTimes(3); + }); + + it('returns empty verses list when chapter meta has no verses', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + jsonResponse({ id: 101, passage_id: 'GEN.1', verses: [] }) + ); + + const result = await getChapterText(1, 'GEN', 1); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.verses).toHaveLength(0); + } + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + }); + + it('fails the whole chapter when any passage fetch fails during fan-out', async () => { + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(jsonResponse(mockChapterMetaResponseBody)) + .mockResolvedValueOnce(jsonResponse(mockPassage1Body)) + .mockResolvedValueOnce(jsonResponse({ message: 'Verse 2 missing' }, 404)); + + const result = await getChapterText(1, 'GEN', 1); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe(ErrorCode.YOUVERSION_SERVICE_UNAVAILABLE); + } + }); + + it('returns failure if chapter meta fetch fails', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + jsonResponse({ message: 'Chapter not found' }, 404) + ); + + const result = await getChapterText(1, 'GEN', 999); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe(ErrorCode.YOUVERSION_SERVICE_UNAVAILABLE); + } + }); + }); + + // ─── Pagination ───────────────────────────────────────────────────────────── + + describe('getBibles — pagination', () => { + it('accumulates results across multiple pages when next_page_token is present', async () => { + const page1Bible = { ...mockBible, id: 1 }; + const page2Bible = { + ...mockBible, + id: 2, + abbreviation: 'ESV', + localized_abbreviation: 'ESV', + title: 'English Standard Version', + localized_title: 'English Standard Version', + }; + + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(jsonResponse({ data: [page1Bible], next_page_token: 'tok-p2' })) + .mockResolvedValueOnce(jsonResponse({ data: [page2Bible] })); + + const result = await getBibles('eng'); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data).toHaveLength(2); + expect(result.data.map((b) => b.id)).toEqual([1, 2]); + } + expect(fetchSpy).toHaveBeenCalledTimes(2); + // Second call must include the page token from the first response. + expect(String(fetchSpy.mock.calls[1]![0])).toContain('page_token=tok-p2'); + }); + + it('returns YOUVERSION_SERVICE_UNAVAILABLE when the page cap is exceeded', async () => { + // Use mockImplementation so each call gets a fresh Response (body streams are one-shot). + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockImplementation(() => + Promise.resolve(jsonResponse({ data: [mockBible], next_page_token: 'repeating' })) + ); + + const result = await getBibles('eng'); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe(ErrorCode.YOUVERSION_SERVICE_UNAVAILABLE); + expect(result.error.message).toContain('page cap'); + } + // Should have made exactly MAX_BIBLES_PAGES = 20 upstream calls before failing. + expect(fetchSpy).toHaveBeenCalledTimes(20); + }); + }); + + // ─── 429 retry ────────────────────────────────────────────────────────────── + + describe('getChapterText — 429 retry', () => { + const singleVerseMeta = { + id: 101, + passage_id: 'GEN.1', + verses: [{ id: 1, passage_id: 'GEN.1.1', human_reference: 'Genesis 1:1', usfm: ['v 1'] }], + }; + + it('retries a 429 passage fetch and succeeds on the next attempt', async () => { + vi.useFakeTimers(); + + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(jsonResponse(singleVerseMeta)) + .mockResolvedValueOnce(new Response('', { status: 429, statusText: 'Too Many Requests' })) + .mockResolvedValueOnce(jsonResponse(mockPassage1Body)); + + const resultPromise = getChapterText(1, 'GEN', 1); + // Advance past DEFAULT_RETRY_DELAY_MS * 1 = 1_000 ms + await vi.advanceTimersByTimeAsync(2_000); + const result = await resultPromise; + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.verses[0]?.verseNumber).toBe(1); + } + // meta + initial 429 + successful retry + expect(fetchSpy).toHaveBeenCalledTimes(3); + }); + + it('uses the Retry-After header value as the retry delay when present', async () => { + vi.useFakeTimers(); + + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(jsonResponse(singleVerseMeta)) + .mockResolvedValueOnce( + new Response('', { + status: 429, + statusText: 'Too Many Requests', + headers: { 'Retry-After': '10' }, + }) + ) + .mockResolvedValueOnce(jsonResponse(mockPassage1Body)); + + const resultPromise = getChapterText(1, 'GEN', 1); + + // Advance only 9 s — retry must not have fired yet. + await vi.advanceTimersByTimeAsync(9_000); + expect(fetchSpy).toHaveBeenCalledTimes(2); // meta + initial 429 only + + // Advance past the full 10 s Retry-After window. + await vi.advanceTimersByTimeAsync(2_000); + const result = await resultPromise; + + expect(result.ok).toBe(true); + expect(fetchSpy).toHaveBeenCalledTimes(3); + }); + + it('falls back to DEFAULT_RETRY_DELAY_MS * attempt when Retry-After is absent', async () => { + vi.useFakeTimers(); + + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(jsonResponse(singleVerseMeta)) + .mockResolvedValueOnce(new Response('', { status: 429, statusText: 'Too Many Requests' })) + .mockResolvedValueOnce(jsonResponse(mockPassage1Body)); + + const resultPromise = getChapterText(1, 'GEN', 1); + + // Advance only 500 ms — DEFAULT_RETRY_DELAY_MS * 1 = 1_000 ms, so retry must not fire yet. + await vi.advanceTimersByTimeAsync(500); + expect(fetchSpy).toHaveBeenCalledTimes(2); // meta + initial 429 only + + await vi.advanceTimersByTimeAsync(1_000); + const result = await resultPromise; + + expect(result.ok).toBe(true); + expect(fetchSpy).toHaveBeenCalledTimes(3); + }); + + it('returns error after exhausting MAX_429_RETRIES (3) retries', async () => { + vi.useFakeTimers(); + + // Use mockImplementation after the meta call so each 429 gets a fresh Response. + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(jsonResponse(singleVerseMeta)) + // initial + 3 retries — all 429; factory ensures each gets a fresh body stream + .mockImplementation(() => + Promise.resolve(new Response('', { status: 429, statusText: 'Too Many Requests' })) + ); + + const resultPromise = getChapterText(1, 'GEN', 1); + // Cumulative retry delays: 1_000 + 2_000 + 3_000 = 6_000 ms + await vi.advanceTimersByTimeAsync(10_000); + const result = await resultPromise; + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe(ErrorCode.YOUVERSION_SERVICE_UNAVAILABLE); + } + // meta + 4 passage fetches (initial + 3 retries) + expect(fetchSpy).toHaveBeenCalledTimes(5); + }); + }); + + // ─── Verse filtering and ordering ─────────────────────────────────────────── + + describe('getChapterText — verse filtering and ordering', () => { + it('skips non-numeric passage_id segments such as INTRO verses', async () => { + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce( + jsonResponse({ + id: 101, + passage_id: 'GEN.1', + verses: [ + { id: 0, passage_id: 'GEN.1.INTRO', human_reference: 'Genesis 1 intro', usfm: [] }, + { id: 1, passage_id: 'GEN.1.1', human_reference: 'Genesis 1:1', usfm: ['v 1'] }, + ], + }) + ) + .mockResolvedValueOnce( + jsonResponse({ id: 'GEN.1.INTRO', passage_id: 'GEN.1.INTRO', content: 'Intro text' }) + ) + .mockResolvedValueOnce(jsonResponse(mockPassage1Body)); + + const result = await getChapterText(1, 'GEN', 1); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.verses).toHaveLength(1); + expect(result.data.verses[0]?.verseNumber).toBe(1); + expect(result.data.verses.map((v) => v.passageId)).not.toContain('GEN.1.INTRO'); + } + }); + + it('returns verses sorted ascending by verse number regardless of fetch completion order', async () => { + // Meta lists verse 2 before verse 1 — output must still be [1, 2]. + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce( + jsonResponse({ + id: 101, + passage_id: 'GEN.1', + verses: [ + { id: 2, passage_id: 'GEN.1.2', human_reference: 'Genesis 1:2', usfm: ['v 2'] }, + { id: 1, passage_id: 'GEN.1.1', human_reference: 'Genesis 1:1', usfm: ['v 1'] }, + ], + }) + ) + .mockResolvedValueOnce(jsonResponse(mockPassage2Body)) + .mockResolvedValueOnce(jsonResponse(mockPassage1Body)); + + const result = await getChapterText(1, 'GEN', 1); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.verses.map((v) => v.verseNumber)).toEqual([1, 2]); + } + }); + }); +}); diff --git a/src/lib/services/youversion/youversion.client.ts b/src/lib/services/youversion/youversion.client.ts new file mode 100644 index 00000000..7e927024 --- /dev/null +++ b/src/lib/services/youversion/youversion.client.ts @@ -0,0 +1,455 @@ +import type { Result } from '@/lib/types'; + +import env from '@/env'; +import { logger } from '@/lib/logger'; +import { ErrorCode, ErrorMessages } from '@/lib/types'; + +import type { + YouVersionBible, + YouVersionChapterResponse, + YouVersionChapterText, + YouVersionPassageResponse, +} from './youversion.types'; + +import { + youVersionBiblesResponseSchema, + youVersionChapterResponseSchema, + youVersionPassageResponseSchema, +} from './youversion.types'; + +const DEFAULT_TIMEOUT_MS = 30_000; +/** Max concurrent upstream passage requests per chapter fetch. */ +const PASSAGE_CONCURRENCY = 5; +/** Max retry attempts for HTTP 429 responses. */ +const MAX_429_RETRIES = 3; +/** Fallback retry delay (ms) when no Retry-After header is present. */ +const DEFAULT_RETRY_DELAY_MS = 1_000; +/** Total wall-clock budget for a complete chapter fetch (meta + all passage fan-out). */ +const CHAPTER_TOTAL_TIMEOUT_MS = 120_000; +/** Max pages consumed from the Bibles list endpoint to guard against infinite pagination. */ +const MAX_BIBLES_PAGES = 20; +/** Cap on upstream body text echoed into logs. */ +const MAX_LOGGED_BODY_CHARS = 300; +/** Cap on zod issues echoed into logs. */ +const MAX_LOGGED_SCHEMA_ISSUES = 3; + +/** Secret-bearing fields an upstream might echo back into an error body. */ +const SECRET_FIELD_PATTERN = + /("?(?:api[-_]?key|token|access[-_]?token|refresh[-_]?token|secret|password|authorization|x-yvp-app-key)"?\s*[:=]\s*)("?)([^"',}\s]+)\2/gi; + +/** + * Strip credentials from anything we echo out of an upstream response. + */ +function redactSecrets(text: string): string { + let out = text.replace(SECRET_FIELD_PATTERN, '$1$2[redacted]$2'); + out = out.replace(/\bBearer\s+[\w.~+/=-]+/gi, 'Bearer [redacted]'); + const key = env.YOUVERSION_API_KEY?.trim(); + if (key && key.length >= 8) { + out = out.split(key).join('[redacted]'); + } + return out; +} + +/** + * Structured error shape used internally by this module. + * `httpStatus` and `retryAfterSeconds` are only present on non-2xx HTTP responses; + * callers outside this file rely only on `code` and `message`. + */ +interface YouVersionHttpError { + code: ErrorCode; + message: string; + /** HTTP status code returned by the upstream (absent for network/timeout errors). */ + httpStatus?: number; + /** Parsed `Retry-After` header value in seconds (absent when the header is missing). */ + retryAfterSeconds?: number; +} + +function youVersionError( + code: ErrorCode, + detail?: string, + extra?: Pick +): { ok: false; error: YouVersionHttpError } { + const base = ErrorMessages[code]; + return { + ok: false, + error: { + code, + message: detail ? `${base}: ${redactSecrets(detail)}` : base, + ...extra, + }, + }; +} + +export function isYouVersionConfigured(): boolean { + return Boolean(env.YOUVERSION_API_KEY?.trim()); +} + +function buildUrl(path: string, query?: URLSearchParams): string { + const base = env.YOUVERSION_API_URL.replace(/\/+$/, ''); + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + const qs = query && query.toString() ? `?${query.toString()}` : ''; + return `${base}${normalizedPath}${qs}`; +} + +function safeJsonParse(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return undefined; + } +} + +function bodySnippet(raw: string): string { + const trimmed = raw.trim().replace(/\s+/g, ' '); + if (!trimmed) return ''; + return trimmed.length > MAX_LOGGED_BODY_CHARS + ? `${trimmed.slice(0, MAX_LOGGED_BODY_CHARS)}…[truncated]` + : trimmed; +} + +function schemaIssueSummary(error: unknown): string { + const issues = (error as { issues?: Array<{ path?: unknown[]; message?: string }> } | undefined) + ?.issues; + if (!Array.isArray(issues) || issues.length === 0) return 'no issue detail'; + const shown = issues + .slice(0, MAX_LOGGED_SCHEMA_ISSUES) + .map((i) => `${(i.path ?? []).join('.') || ''}: ${i.message ?? 'invalid'}`) + .join('; '); + const extra = + issues.length > MAX_LOGGED_SCHEMA_ISSUES + ? ` (+${issues.length - MAX_LOGGED_SCHEMA_ISSUES} more)` + : ''; + return `${shown}${extra}`; +} + +async function youVersionGet( + path: string, + schema: { + safeParse: (data: unknown) => { success: true; data: T } | { success: false; error?: unknown }; + }, + query?: URLSearchParams, + timeoutMs = DEFAULT_TIMEOUT_MS, + signal?: AbortSignal +): Promise<{ ok: false; error: YouVersionHttpError } | { ok: true; data: T }> { + const target = query && query.toString() ? `${path}?${query.toString()}` : path; + const fail = ( + detail: string, + extra?: Pick + ) => + youVersionError(ErrorCode.YOUVERSION_SERVICE_UNAVAILABLE, `GET ${target} — ${detail}`, extra); + + if (!isYouVersionConfigured()) { + return fail('YOUVERSION_API_KEY is not configured'); + } + + try { + if (new URL(env.YOUVERSION_API_URL).protocol !== 'https:') { + return fail('YOUVERSION_API_URL must use HTTPS'); + } + } catch { + return fail('YOUVERSION_API_URL is not a valid URL'); + } + + const url = buildUrl(path, query); + const controller = new AbortController(); + const startedAt = Date.now(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + // Combine the per-request timeout with any caller-supplied overall budget signal. + const fetchSignal = signal ? AbortSignal.any([controller.signal, signal]) : controller.signal; + + let rawBody: string; + let response: Response; + try { + response = await fetch(url, { + method: 'GET', + headers: { + 'x-yvp-app-key': env.YOUVERSION_API_KEY!, + }, + signal: fetchSignal, + }); + rawBody = await response.text(); + } catch (error) { + const elapsedMs = Date.now() - startedAt; + const isAbort = error instanceof Error && error.name === 'AbortError'; + if (isAbort) { + // Distinguish between the overall chapter budget firing vs. the per-request timeout. + if (signal?.aborted) { + return fail(`chapter budget exceeded (elapsed ${elapsedMs}ms)`); + } + return fail(`request timed out after ${timeoutMs}ms (elapsed ${elapsedMs}ms)`); + } + const cause = error instanceof Error ? `${error.name}: ${error.message}` : String(error); + return fail(`YouVersion unreachable after ${elapsedMs}ms (${cause})`); + } finally { + clearTimeout(timeoutId); + } + + const elapsedMs = Date.now() - startedAt; + + if (!response.ok) { + // Read Retry-After as an integer seconds value; ignore if absent or non-numeric. + const retryAfterRaw = response.headers.get('retry-after'); + const retryAfterSeconds = + retryAfterRaw !== null && /^\d+$/.test(retryAfterRaw.trim()) + ? Number(retryAfterRaw.trim()) + : undefined; + + return fail( + `YouVersion returned HTTP ${response.status} ${response.statusText} in ${elapsedMs}ms; ` + + `upstream body: ${bodySnippet(rawBody)}`, + { httpStatus: response.status, retryAfterSeconds } + ); + } + + const parsed = rawBody.trim() ? safeJsonParse(rawBody) : {}; + if (parsed === undefined) { + return fail( + `HTTP ${response.status} but body was not valid JSON ` + + `(content-type: ${response.headers.get('content-type') ?? 'none'}, ` + + `${rawBody.length} chars): ${bodySnippet(rawBody)}` + ); + } + + const validated = schema.safeParse(parsed); + if (!validated.success) { + return fail( + `HTTP ${response.status} response payload failed schema validation ` + + `(content-type: ${response.headers.get('content-type') ?? 'none'}, ` + + `${rawBody.length} chars) — ${schemaIssueSummary(validated.error)}; ` + + `body: ${bodySnippet(rawBody)}` + ); + } + + return { ok: true, data: validated.data }; +} + +// ─── Public client functions ────────────────────────────────────────────────── + +/** + * Fetch YouVersion Bibles for a given language tag. + * Follows `next_page_token` pagination until all pages are consumed. + * Fails with YOUVERSION_SERVICE_UNAVAILABLE if pagination exceeds MAX_BIBLES_PAGES. + * Returns the combined `data` array (strips pagination envelope). + */ +export async function getBibles(languageTag: string): Promise> { + // YouVersion API requires raw `language_ranges[]` query key unencoded (without %5B%5D) + const encodedTag = encodeURIComponent(languageTag); + const basePathWithQuery = `/bibles?language_tag=${encodedTag}&language_ranges[]=${encodedTag}`; + + const allBibles: YouVersionBible[] = []; + let pageToken: string | undefined; + let page = 0; + + while (true) { + if (page >= MAX_BIBLES_PAGES) { + return youVersionError( + ErrorCode.YOUVERSION_SERVICE_UNAVAILABLE, + `getBibles exceeded page cap (${MAX_BIBLES_PAGES}) for language "${languageTag}"` + ); + } + + const pathWithQuery = pageToken + ? `${basePathWithQuery}&page_token=${encodeURIComponent(pageToken)}` + : basePathWithQuery; + + const result = await youVersionGet(pathWithQuery, youVersionBiblesResponseSchema); + if (!result.ok) return result; + + allBibles.push(...result.data.data); + page++; + + if (!result.data.next_page_token) break; + pageToken = result.data.next_page_token; + } + + return { ok: true, data: allBibles }; +} + +/** + * Fetch chapter metadata (ordered verse list with passage_ids). + * Used internally by getChapterText — not exposed as its own route. + */ +export async function getChapterMeta( + bibleId: number, + bookId: string, + chapterId: number +): Promise> { + return youVersionGet( + `/bibles/${bibleId}/books/${encodeURIComponent(bookId)}/chapters/${chapterId}`, + youVersionChapterResponseSchema + ); +} + +/** + * Fetch a single passage by its passage_id (e.g. "GEN.1.5"). + */ +export async function getPassage( + bibleId: number, + passageId: string, + signal?: AbortSignal +): Promise> { + return youVersionGet( + `/bibles/${bibleId}/passages/${encodeURIComponent(passageId)}`, + youVersionPassageResponseSchema, + undefined, + DEFAULT_TIMEOUT_MS, + signal + ); +} + +/** + * Fetch a single passage with bounded retry on HTTP 429. + * Retries up to MAX_429_RETRIES times, honouring the upstream `Retry-After` header + * when present and capping at 60 s; falls back to DEFAULT_RETRY_DELAY_MS * attempt. + * Respects `signal` as a shared chapter-level budget — bails immediately if it fires. + */ +async function getPassageWithRetry( + bibleId: number, + passageId: string, + signal?: AbortSignal +): Promise> { + let attempt = 0; + while (true) { + // Bail immediately if the chapter budget has already been exhausted. + if (signal?.aborted) { + return youVersionError( + ErrorCode.YOUVERSION_SERVICE_UNAVAILABLE, + `chapter budget exceeded before passage ${passageId}` + ); + } + + const result = await getPassage(bibleId, passageId, signal); + if (result.ok) return result; + + // Detect 429 via the structured httpStatus field — not string matching. + const is429 = result.error.httpStatus === 429; + if (!is429 || attempt >= MAX_429_RETRIES) return result; + + attempt++; + // Use the Retry-After value plumbed through from the response header, if present. + const delayMs = + result.error.retryAfterSeconds !== undefined + ? Math.min(result.error.retryAfterSeconds * 1_000, 60_000) + : DEFAULT_RETRY_DELAY_MS * attempt; + + logger.warn({ + message: `YouVersion 429 on passage ${passageId}; retrying in ${delayMs}ms (attempt ${attempt}/${MAX_429_RETRIES})`, + context: { bibleId, passageId, attempt, delayMs }, + }); + + // Sleep for the retry delay, but exit early if the chapter budget fires. + await new Promise((resolve) => { + const tid = setTimeout(resolve, delayMs); + signal?.addEventListener( + 'abort', + () => { + clearTimeout(tid); + resolve(); + }, + { once: true } + ); + }); + } +} + +/** + * Run an array of async tasks with a bounded concurrency limit. + */ +async function withConcurrencyLimit( + tasks: Array<() => Promise>, + limit: number +): Promise { + const results: T[] = Array.from({ length: tasks.length }); + let nextIndex = 0; + + async function worker(): Promise { + while (true) { + const index = nextIndex++; + if (index >= tasks.length) return; + results[index] = await tasks[index](); + } + } + + const workers = Array.from({ length: Math.min(limit, tasks.length) }, () => worker()); + await Promise.all(workers); + return results; +} + +/** + * Batch helper — fetch all verse texts for a chapter in one server call. + * + * 1. Calls getChapterMeta to get the ordered verse passage_id list. + * 2. Fans out one getPassage per verse, bounded to PASSAGE_CONCURRENCY in-flight + * requests, with bounded retry on HTTP 429 responses (honouring Retry-After). + * A shared CHAPTER_TOTAL_TIMEOUT_MS budget aborts all in-flight fetches if the + * total operation runs too long, preventing unbounded hangs on large chapters. + * 3. If any passage fails, the entire chapter request fails — no silent partial results. + * 4. On success, assembles results into YouVersionChapterText ordered by verse number. + * + * The fan-out stays server-side so the client sends exactly one request, + * and a single API key handles the full chapter load without leaking to the browser. + */ +export async function getChapterText( + bibleId: number, + bookId: string, + chapterId: number +): Promise> { + // Step 1: chapter meta + const metaResult = await getChapterMeta(bibleId, bookId, chapterId); + if (!metaResult.ok) return metaResult; + + const { verses: verseMetas } = metaResult.data; + + if (verseMetas.length === 0) { + return { + ok: true, + data: { bibleId, bookId, chapterId, verses: [] }, + }; + } + + // Step 2: concurrency-limited fan-out with 429 retry, bounded by a total chapter budget. + // AbortSignal.timeout() fires automatically — no manual cleanup needed. + const chapterSignal = AbortSignal.timeout(CHAPTER_TOTAL_TIMEOUT_MS); + const tasks = verseMetas.map( + (vm) => () => getPassageWithRetry(bibleId, vm.passage_id, chapterSignal) + ); + const passageResults = await withConcurrencyLimit(tasks, PASSAGE_CONCURRENCY); + + // Step 3: assemble — any failure causes the whole chapter to fail + const verses: YouVersionChapterText['verses'] = []; + for (let i = 0; i < verseMetas.length; i++) { + const meta = verseMetas[i]; + const passageResult = passageResults[i]; + + if (!passageResult.ok) { + logger.warn({ + message: 'YouVersion passage fetch failed', + context: { + bibleId, + passageId: meta.passage_id, + error: passageResult.error.message, + }, + }); + return youVersionError( + ErrorCode.YOUVERSION_SERVICE_UNAVAILABLE, + `passage ${meta.passage_id} failed: ${passageResult.error.message}` + ); + } + + // passage_id format: "GEN.1.5" — verse number is the third segment + const verseNumber = Number.parseInt(meta.passage_id.split('.')[2] ?? '0', 10); + if (!Number.isInteger(verseNumber) || verseNumber <= 0) continue; + + verses.push({ + verseNumber, + passageId: meta.passage_id, + content: passageResult.data.content, + }); + } + + // Sort ascending — concurrency-limited results can arrive out of order + verses.sort((a, b) => a.verseNumber - b.verseNumber); + + return { ok: true, data: { bibleId, bookId, chapterId, verses } }; +} diff --git a/src/lib/services/youversion/youversion.errors.ts b/src/lib/services/youversion/youversion.errors.ts new file mode 100644 index 00000000..f51c4c73 --- /dev/null +++ b/src/lib/services/youversion/youversion.errors.ts @@ -0,0 +1,33 @@ +import type { Context } from 'hono'; + +import type { AppBindings, AppError } from '@/lib/types'; + +import { ErrorCode, ErrorMessages, getHttpStatus } from '@/lib/types'; + +/** + * Map YouVersion-backed route failures to HTTP JSON. Upstream detail is logged + * server-side; clients only see the generic YOUVERSION_SERVICE_UNAVAILABLE message. + */ +export function youVersionErrorResponse(c: Context, error: AppError) { + if (error.code === ErrorCode.YOUVERSION_SERVICE_UNAVAILABLE) { + c.get('logger').error( + { + youVersionError: error.message, + code: error.code, + method: c.req.method, + path: c.req.path, + requestId: c.get('requestId'), + userId: c.get('user')?.id, + activeOrgId: c.get('activeOrgId'), + }, + // Detail is repeated in the message so it survives console-only transports, + // which drop structured properties (see lib/logger.ts). + `YouVersion upstream failure: ${error.message}` + ); + return c.json( + { message: ErrorMessages[ErrorCode.YOUVERSION_SERVICE_UNAVAILABLE] }, + getHttpStatus(error) as never + ); + } + return c.json({ message: error.message }, getHttpStatus(error) as never); +} diff --git a/src/lib/services/youversion/youversion.types.ts b/src/lib/services/youversion/youversion.types.ts new file mode 100644 index 00000000..24efeaae --- /dev/null +++ b/src/lib/services/youversion/youversion.types.ts @@ -0,0 +1,88 @@ +import { z } from '@hono/zod-openapi'; + +// ─── Bible ──────────────────────────────────────────────────────────────────── + +export const youVersionBibleSchema = z + .object({ + id: z.number().int(), + abbreviation: z.string(), + localized_abbreviation: z.string(), + title: z.string(), + localized_title: z.string(), + language_tag: z.string(), + info: z.string().nullable().optional(), + copyright: z.string().nullable().optional(), + publisher_url: z.string().nullable().optional(), + promotional_content: z.string().nullable().optional(), + youversion_deep_link: z.string().nullable().optional(), + organization_id: z.string().nullable().optional(), + books: z.array(z.string()).nullable().optional(), + }) + .openapi('YouVersionBible'); + +export type YouVersionBible = z.infer; + +/** Internal — wraps the paginated /bibles response. Never sent to clients. */ +export const youVersionBiblesResponseSchema = z.object({ + data: z.array(youVersionBibleSchema), + next_page_token: z.string().nullable().optional(), + total_size: z.number().int().nullable().optional(), +}); + +// ─── Chapter metadata ───────────────────────────────────────────────────────── + +export const youVersionVerseMetaSchema = z.object({ + id: z.union([z.string(), z.number()]), + passage_id: z.string(), + title: z.union([z.string(), z.number()]).nullable().optional(), +}); + +export type YouVersionVerseMeta = z.infer; + +export const youVersionChapterResponseSchema = z.object({ + id: z.union([z.string(), z.number()]), + passage_id: z.string(), + title: z.union([z.string(), z.number()]).nullable().optional(), + verses: z.array(youVersionVerseMetaSchema), +}); + +export type YouVersionChapterResponse = z.infer; + +// ─── Passage ────────────────────────────────────────────────────────────────── + +export const youVersionPassageResponseSchema = z.object({ + id: z.string(), + content: z.string(), + reference: z.string().nullable().optional(), +}); + +export type YouVersionPassageResponse = z.infer; + +// ─── Batch chapter text (server-assembled, exposed to fluent-web) ───────────── + +/** + * One verse as returned by the batch chapter-text endpoint. + * `passage_id` format: "GEN.1.5" — third segment is the verse number. + */ +export const youVersionBibleVerseSchema = z.object({ + verseNumber: z.number().int(), + passageId: z.string(), + content: z.string(), +}); + +export type YouVersionBibleVerse = z.infer; + +/** + * Response shape for `GET /youversion/bibles/{bibleId}/chapters/{chapterId}/text`. + * The server fans out passage fetches internally so the client sends one request. + */ +export const youVersionChapterTextSchema = z + .object({ + bibleId: z.number().int(), + bookId: z.string(), + chapterId: z.number().int(), + verses: z.array(youVersionBibleVerseSchema), + }) + .openapi('YouVersionChapterText'); + +export type YouVersionChapterText = z.infer; diff --git a/src/lib/types.ts b/src/lib/types.ts index a381af11..118ae12a 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -82,6 +82,8 @@ export const ErrorCode = { AI_TOOL_EXECUTION_FAILED: 'AI_TOOL_EXECUTION_FAILED', // Aquifer (translation resources) upstream errors → HTTP 502 AQUIFER_SERVICE_UNAVAILABLE: 'AQUIFER_SERVICE_UNAVAILABLE', + // YouVersion (Bible text for the Reference column) upstream errors → HTTP 502 + YOUVERSION_SERVICE_UNAVAILABLE: 'YOUVERSION_SERVICE_UNAVAILABLE', // DBL upstream errors DBL_SERVICE_UNAVAILABLE: 'DBL_SERVICE_UNAVAILABLE', DBL_NOT_CONFIGURED: 'DBL_NOT_CONFIGURED', @@ -132,6 +134,7 @@ export const ErrorMessages: Record = { AI_SERVICE_UNAVAILABLE: 'AI service is unavailable', AI_TOOL_EXECUTION_FAILED: 'AI tool execution failed', AQUIFER_SERVICE_UNAVAILABLE: 'Aquifer service is unavailable', + YOUVERSION_SERVICE_UNAVAILABLE: 'YouVersion service is unavailable', DBL_SERVICE_UNAVAILABLE: 'DBL API is unavailable', DBL_NOT_CONFIGURED: 'DBL API key is not configured', LANGUAGE_NOT_FOUND: 'Language not found', @@ -148,6 +151,7 @@ export const ErrorHttpStatus: Record = { AI_SERVICE_UNAVAILABLE: 502, AI_TOOL_EXECUTION_FAILED: 502, AQUIFER_SERVICE_UNAVAILABLE: 502, + YOUVERSION_SERVICE_UNAVAILABLE: 502, DBL_SERVICE_UNAVAILABLE: 502, DBL_NOT_CONFIGURED: 503, LANGUAGE_NOT_FOUND: 404,