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
15 changes: 15 additions & 0 deletions .changeset/fix-highlights-api-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@youversion/platform-core': minor
'@youversion/platform-react-hooks': minor
---

Fix `HighlightsClient` to match the live highlights API contract. The client previously sent requests the API rejects on every call (401/400), so highlights could never be fetched, created, or deleted.

- Auth token is now sent as an `Authorization: Bearer <token>` header instead of a `lat` query parameter
- Query/body fields now use the API's `bible_id` naming on the wire (the SDK keeps `version_id` in its public types and maps at the boundary)
- `createHighlight` now sends the required `{ request_id, highlight: { ... } }` envelope (`request_id` is a unique per-request id the API requires)
- `getHighlights` now requires `version_id` and `passage_id` (verse or chapter USFM), and `deleteHighlight` requires `version_id`, matching the API's required parameters; `useHighlights` options are updated accordingly
- `getHighlights` now treats a `204` (no highlights for the passage) as an empty collection instead of throwing
- `createHighlight` normalizes `color` to lowercase before sending, since the API accepts lowercase hex only
- Added `getRecentColors()` (`GET /v1/highlights/recent-colors`) to `HighlightsClient` and exposed it from `useHighlights` for building color pickers
- API responses are validated with Zod and mapped from the wire shape
92 changes: 72 additions & 20 deletions packages/core/src/__tests__/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { http, HttpResponse } from 'msw';
import type { Collection, Highlight, Language } from '../types';
import type { Collection, Language } from '../types';
import { mockLanguages } from './MockLanguages';
import { mockVersions, mockVersionKJV } from './MockVersions';
import { mockOrganizations } from './MockOrganizations';
Expand All @@ -22,6 +22,17 @@ if (!apiHost) {
throw new Error('YVP_API_HOST environment variable must be set to run handler tests.');
}

/** Mirrors the live API's 401 when no `Authorization: Bearer` header is sent. */
function requireBearerAuth(request: Request): Response | null {
if (request.headers.get('Authorization')?.startsWith('Bearer ')) {
return null;
}
return HttpResponse.json(
{ error: 'invalid_request', error_description: 'Missing or invalid Bearer token' },
{ status: 401 },
);
}

export const handlers = [
// Organizations endpoints
http.get(`https://${apiHost}/v1/organizations/:organizationId`, ({ params }) => {
Expand Down Expand Up @@ -87,38 +98,79 @@ export const handlers = [
return HttpResponse.json(response);
}),

// Highlights endpoints
// Highlights endpoints. These handlers intentionally enforce the documented
// API contract (Bearer auth, `bible_id` naming, enveloped POST body) so the
// client cannot drift from it without tests failing.
http.get(`https://${apiHost}/v1/highlights`, ({ request }) => {
const authError = requireBearerAuth(request);
if (authError) return authError;

const url = new URL(request.url);
const bibleId = url.searchParams.get('version_id');
const bibleId = url.searchParams.get('bible_id');
const passageId = url.searchParams.get('passage_id');
if (!bibleId || !passageId) {
return HttpResponse.json(
{ error: 'invalid_request', error_description: 'bible_id and passage_id are required' },
{ status: 400 },
);
}

const highlights: Collection<Highlight> = {
return HttpResponse.json({
data: [
{
version_id: bibleId ? Number(bibleId) : 111,
passage_id: passageId || 'MAT.1.1',
color: 'fffe00',
},
{
version_id: bibleId ? Number(bibleId) : 111,
passage_id: passageId || 'MAT.1.2',
color: '5dff79',
},
{ bible_id: Number(bibleId), passage_id: `${passageId}.1`, color: 'fffe00' },
{ bible_id: Number(bibleId), passage_id: `${passageId}.2`, color: '5dff79' },
],
next_page_token: null,
};
});
}),

return HttpResponse.json(highlights);
http.get(`https://${apiHost}/v1/highlights/recent-colors`, ({ request }) => {
const authError = requireBearerAuth(request);
if (authError) return authError;

return HttpResponse.json({
data: [{ color: 'fffe00' }, { color: '5dff79' }],
});
}),

http.post(`https://${apiHost}/v1/highlights`, async ({ request }) => {
const body = (await request.json()) as Highlight;
const authError = requireBearerAuth(request);
if (authError) return authError;

const body = (await request.json()) as {
request_id?: string;
highlight?: { bible_id?: number; passage_id?: string; color?: string };
};
if (
!body.request_id ||
!body.highlight?.bible_id ||
!body.highlight.passage_id ||
!body.highlight.color
) {
return HttpResponse.json(
{
error: 'invalid_request',
error_description:
'Body must be { request_id, highlight: { bible_id, passage_id, color } }',
},
{ status: 400 },
);
}

return HttpResponse.json(body, { status: 201 });
return HttpResponse.json(body.highlight, { status: 201 });
}),

http.delete(`https://${apiHost}/v1/highlights/:passageId`, () => {
http.delete(`https://${apiHost}/v1/highlights/:passageId`, ({ request }) => {
const authError = requireBearerAuth(request);
if (authError) return authError;

const url = new URL(request.url);
if (!url.searchParams.get('bible_id')) {
return HttpResponse.json(
{ error: 'invalid_request', error_description: 'bible_id is required' },
{ status: 400 },
);
}

return new HttpResponse(null, { status: 204 });
}),

Expand Down
Loading
Loading