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
5 changes: 5 additions & 0 deletions .changeset/reexport-search-images.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@youversion/platform-react-ui': minor
---

Re-export `searchImages` from the UI package entry point.
9 changes: 9 additions & 0 deletions .changeset/search-images-votd.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@youversion/platform-core': minor
---

Add paginated `searchImages` for `GET /v1/search-images`. Pass the same query, version id, and `pageSize` on each page, then pass `nextPageToken` back as `pageToken`. An empty page can still include a token. Empty success bodies, including HTTP 204, reject with `search-images returned no content; expected a JSON response`.

Verse-of-the-day reads move from `/v1/verse_of_the_days` to the canonical `/v1/verse-of-the-days` routes for both single days and the calendar. Pass `language_ranges` to send `language_ranges[]`. Core client methods return `VOTDResponse` records with a guaranteed `image_id: string | null`, including `null` when the API omits the field. The existing `VOTD` type and hook overrides still accept values without `image_id`.

VOTD reads now validate responses instead of returning unchecked bodies. `getAllVOTDs` requires a `{ data: [...] }` collection envelope. Both methods reject malformed responses with an `Unexpected verse-of-the-day API response` error, including invalid day, passage, or image field types.
36 changes: 35 additions & 1 deletion packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,12 @@ the CDN. Set `size` to `thumb`, `card`, or `full` on `image_url` when you want
a smaller rendition.

```ts
import { ApiClient, ImagesClient } from '@youversion/platform-core';
import { ApiClient, BibleClient, ImagesClient, SearchClient } from '@youversion/platform-core';

const apiClient = new ApiClient({ appKey: 'YOUR_APP_KEY' });
const images = new ImagesClient(apiClient);
const search = new SearchClient(apiClient);
const bibleClient = new BibleClient(apiClient);

const page = await images.getImages({
passage_id: 'JHN.3.16',
Expand All @@ -92,6 +94,38 @@ if (!first) {
}
```

Search verse-art images with `searchImages`. Keep the query, version id, and `pageSize` the same on every page. Pass `nextPageToken` back as `pageToken`. The last page uses `null`. An earlier page can be empty and still return a token, including an empty string. Pass each hit `id` to `getImage` unchanged.

```ts
let pageToken: string | null = null;

do {
const options: { pageSize: number; pageToken?: string } = { pageSize: 10 };
if (pageToken !== null) {
options.pageToken = pageToken;
}
const found = await search.searchImages('love', 111, options);
for (const hit of found.images) {
const image = await images.getImage(hit.id);
console.log(hit.passageId, image.image_url);
}
pageToken = found.nextPageToken;
} while (pageToken !== null);
```

`getVOTD` and `getAllVOTDs` call `GET /v1/verse-of-the-days`. Omit `language_ranges` when you want no language query. Supplied ranges are trimmed, lowercased, and normalized from underscores to hyphens (`' EN '` becomes `en`; `en_US` becomes `en-us`). Invalid Basic Language Ranges are rejected before the request. Valid ranges are sent in the supplied order as repeated `language_ranges[]` parameters. The response keeps `day` and `passage_id`. `image_id` is `null` when the API omits it or sends null. Any other string stays unchanged. Pass that string to `getImage`.

```ts
const today = await bibleClient.getVOTD(1, { language_ranges: ['en', 'es'] });
if (today.image_id != null) {
const curated = await images.getImage(today.image_id);
console.log(today.day, today.passage_id, curated.image_url);
}

const calendar = await bibleClient.getAllVOTDs({ language_ranges: ['en'] });
console.log(calendar.data[0]?.passage_id, calendar.data[0]?.image_id);
```

`next_page_token` is `null` on the last page. Pass that value back as
`page_token`, including `null`, to request the next page or the first page.
`total_size` is present only when the API sends a number, which it does when
Expand Down
2 changes: 2 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@
"!dist/search-queries.cjs",
"!dist/search-verses.js",
"!dist/search-verses.cjs",
"!dist/search-images.js",
"!dist/search-images.cjs",
"!dist/search-topics.js",
"!dist/search-topics.cjs",
"!dist/search-unified.js",
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/__tests__/bible.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ describe('BibleClient runtime contracts', () => {
expect(await client.getIndex(206)).toEqual(index);
expect(requests.at(-1)?.pathname).toBe('/v1/bibles/206/index');
expect((await client.getAllVOTDs()).data[0]?.day).toBe(1);
expect(get).toHaveBeenCalledWith('/v1/verse_of_the_days');
expect(requests.at(-1)?.pathname).toBe('/v1/verse-of-the-days');
} finally {
server.events.removeListener('request:start', onRequest);
get.mockRestore();
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/__tests__/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,12 +259,12 @@ export const handlers = [
}),

// Verse Of The Day endpoints
http.get(`https://${apiHost}/v1/verse_of_the_days/:day`, ({ params }) => {
http.get(`https://${apiHost}/v1/verse-of-the-days/:day`, ({ params }) => {
const { day } = params;
const votd = mockAllVOTDs.find((v) => v.day === Number(day));
return HttpResponse.json(votd || mockAllVOTDs[0]);
}),
http.get(`https://${apiHost}/v1/verse_of_the_days`, () => {
http.get(`https://${apiHost}/v1/verse-of-the-days`, () => {
return HttpResponse.json({ data: mockAllVOTDs });
}),
];
227 changes: 227 additions & 0 deletions packages/core/src/__tests__/search-images.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import { describe, expect, it, vi } from 'vitest';
import { http, HttpResponse } from 'msw';
import { ApiClient, getHttpStatus, ImagesClient, SearchClient, searchImages } from '../index';
import { server } from './setup';

const apiHost = process.env.YVP_API_HOST;

const image = {
id: 'abc/def?x',
image_url: 'https://cdn.example/img.png',
passage_id: 'JHN.3.16',
attribution: 'YouVersion',
language_tag: 'en',
width: 1200,
height: 800,
aspect_ratio: 1.5,
};

function urlFromFetchInput(input: RequestInfo | URL | undefined): string {
if (input instanceof Request) return input.url;
if (input instanceof URL) return input.href;
return input ?? '';
}

function createClients() {
const api = new ApiClient({
apiHost,
appKey: 'test-app',
installationId: 'test-installation',
});
return { api, search: new SearchClient(api), images: new ImagesClient(api) };
}

describe.skipIf(Boolean(process.env.INTEGRATION_TESTS))('searchImages', () => {
it('pages image hits and resolves an opaque id with getImage', async () => {
const { api, search, images } = createClients();
const fetchSpy = vi.spyOn(global, 'fetch');

try {
await expect(search.searchImages('', 111)).rejects.toThrow(
'Query must be between 1 and 100 characters',
);
await expect(search.searchImages(' ', 111)).rejects.toThrow(
'Query must be between 1 and 100 characters',
);
await expect(search.searchImages('a'.repeat(101), 111)).rejects.toThrow(
'Query must be between 1 and 100 characters',
);
await expect(search.searchImages('faith', 0)).rejects.toThrow(
'Version ID must be a positive integer',
);
await expect(search.searchImages('faith', 1.5)).rejects.toThrow();
await expect(search.searchImages('faith', 111, { pageSize: 0 })).rejects.toThrow();
await expect(search.searchImages('faith', 111, { pageSize: 100 })).rejects.toThrow();
await expect(search.searchImages('faith', 111, { pageSize: 1.5 })).rejects.toThrow();
expect(fetchSpy).not.toHaveBeenCalled();

server.use(
http.get(`https://${apiHost}/v1/search-images`, ({ request }) => {
const url = new URL(request.url);
const token = url.searchParams.get('page_token');
const pageSize = url.searchParams.get('page_size');
if (token === null && pageSize === null) {
return HttpResponse.json({ images: [], next_page_token: null });
}
if (token === null) {
return HttpResponse.json({
images: [
{ id: 'abc/def?x', passage_id: 'JHN.3.16', category: 'verse-art' },
{ id: '', passage_id: 'JHN.3.16' },
{ id: 'img-bad', passage_id: 'NOT USFM' },
{ id: null, passage_id: 'JHN.3.16' },
{ id: 'img-missing' },
{ id: 'img-2', passage_id: 'PSA.23' },
],
next_page_token: 'page/2?x',
});
}
if (token === 'page/2?x') {
return HttpResponse.json({ images: [], next_page_token: '' });
}
if (token === '') {
return HttpResponse.json({ images: [] });
}
return HttpResponse.json({ images: [], next_page_token: null });
}),
http.get(`https://${apiHost}/v1/images/abc%2Fdef%3Fx`, () => HttpResponse.json(image)),
);

const defaults = await searchImages(api, 'bread of life', 111);
expect(defaults).toEqual({ images: [], nextPageToken: null });

const populated = await search.searchImages('bread of life', 111, { pageSize: 10 });
expect(populated).toEqual({
images: [
{ id: 'abc/def?x', passageId: 'JHN.3.16' },
{ id: 'img-2', passageId: 'PSA.23' },
],
nextPageToken: 'page/2?x',
});

const firstToken = populated.nextPageToken;
if (firstToken === null) {
throw new Error('expected a continuation token');
}
const continued = await search.searchImages('bread of life', 111, {
pageSize: 10,
pageToken: firstToken,
});
expect(continued).toEqual({ images: [], nextPageToken: '' });

const emptyToken = continued.nextPageToken;
if (emptyToken === null) {
throw new Error('expected an empty continuation token');
}
const finalPage = await search.searchImages('bread of life', 111, {
pageSize: 10,
pageToken: emptyToken,
});
expect(finalPage).toEqual({ images: [], nextPageToken: null });

const detail = await images.getImage(populated.images[0]?.id ?? '');
expect(detail).toEqual(image);

const searchCalls = fetchSpy.mock.calls
.map((call) => new URL(urlFromFetchInput(call[0])))
.filter((url) => url.pathname === '/v1/search-images');
expect(searchCalls).toHaveLength(4);

const defaultUrl = searchCalls[0];
expect(defaultUrl?.searchParams.get('query')).toBe('bread of life');
expect(defaultUrl?.searchParams.get('bible_id')).toBe('111');
expect(defaultUrl?.searchParams.has('page_size')).toBe(false);
expect(defaultUrl?.searchParams.has('page_token')).toBe(false);
expect(defaultUrl?.searchParams.has('language_ranges[]')).toBe(false);
expect(defaultUrl?.searchParams.has('user_intent')).toBe(false);
expect(defaultUrl?.searchParams.has('fields[]')).toBe(false);

for (const url of searchCalls.slice(1)) {
expect(url.searchParams.get('query')).toBe('bread of life');
expect(url.searchParams.get('bible_id')).toBe('111');
expect(url.searchParams.get('page_size')).toBe('10');
expect(url.searchParams.has('language_ranges[]')).toBe(false);
}
expect(searchCalls[1]?.searchParams.has('page_token')).toBe(false);
expect(searchCalls[2]?.searchParams.get('page_token')).toBe('page/2?x');
expect(searchCalls[3]?.searchParams.get('page_token')).toBe('');

const firstHeaders = new Headers(fetchSpy.mock.calls[0]?.[1]?.headers);
expect(firstHeaders.get('X-YVP-App-Key')).toBe('test-app');
expect(firstHeaders.get('Authorization')).toBeNull();

const detailUrl = urlFromFetchInput(fetchSpy.mock.calls.at(-1)?.[0]);
expect(detailUrl).toContain('/v1/images/abc%2Fdef%3Fx');
expect(detailUrl).not.toContain('abc%252F');
} finally {
fetchSpy.mockRestore();
}
});

it('rejects a broken search-images body, HTTP 204, and HTTP errors', async () => {
const { api } = createClients();

server.use(
http.get(`https://${apiHost}/v1/search-images`, ({ request }) => {
const url = new URL(request.url);
const token = url.searchParams.get('page_token');
if (token === 'missing-images') {
return HttpResponse.json({ next_page_token: null });
}
if (token === 'images-null') {
return HttpResponse.json({ images: null, next_page_token: null });
}
if (token === 'token-number') {
return HttpResponse.json({ images: [], next_page_token: 1 });
}
if (token === 'empty-body') {
return new HttpResponse('', {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (token === 'no-content') {
return new HttpResponse(null, { status: 204 });
}
if (token === 'http-400') {
return HttpResponse.json({ message: 'bad query' }, { status: 400 });
}
if (token === 'explicit-null') {
return HttpResponse.json({ images: [], next_page_token: null });
}
return HttpResponse.json({ images: [] });
}),
);

await expect(searchImages(api, 'faith', 111, { pageToken: 'explicit-null' })).resolves.toEqual({
images: [],
nextPageToken: null,
});
await expect(searchImages(api, 'faith', 111, { pageToken: 'missing-images' })).rejects.toThrow(
/Unexpected search-images API response/,
);
await expect(searchImages(api, 'faith', 111, { pageToken: 'images-null' })).rejects.toThrow(
/Unexpected search-images API response/,
);
await expect(searchImages(api, 'faith', 111, { pageToken: 'token-number' })).rejects.toThrow(
/Unexpected search-images API response/,
);
await expect(searchImages(api, 'faith', 111, { pageToken: 'empty-body' })).rejects.toThrow(
'search-images returned no content; expected a JSON response',
);
await expect(searchImages(api, 'faith', 111, { pageToken: 'no-content' })).rejects.toThrow(
'search-images returned no content; expected a JSON response',
);

try {
await searchImages(api, 'faith', 111, { pageToken: 'http-400' });
throw new Error('expected searchImages to reject');
} catch (error) {
expect(getHttpStatus(error)).toBe(400);
expect(error).toBeInstanceOf(Error);
if (error instanceof Error) {
expect(error.message).not.toMatch(/Unexpected search-images API response/);
}
}
});
});
2 changes: 1 addition & 1 deletion packages/core/src/__tests__/search-verses.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { parseSearchLanguageRange } from '../schemas/language-range';
import { describe, it, expect, vi } from 'vitest';
import { http, HttpResponse } from 'msw';
import { ApiClient } from '../client';
Expand All @@ -7,7 +8,6 @@ import {
SearchTextQuerySchema,
clampSearchText,
isValidStructuralUsfmReference,
parseSearchLanguageRange,
} from '../schemas/search';
import { server } from './setup';

Expand Down
Loading
Loading