fix(core): align HighlightsClient with the live highlights API contract#279
Merged
Conversation
The client sent requests the API rejects on every call, so highlights
could never be fetched, created, or deleted:
- Send the OAuth token as an Authorization: Bearer header (the API
rejects the previous lat query parameter with 401)
- Use the API's bible_id naming on the wire for GET/POST/DELETE while
keeping version_id in the SDK's public types, mapped at the boundary
- Send the required { request_id, highlight: { ... } } envelope on
create for idempotent retries
- Require version_id and passage_id on getHighlights and version_id on
deleteHighlight, matching the API's required parameters
- Validate responses with Zod and map from the wire shape
- Un-skip the highlights tests and make the MSW handlers enforce the
documented contract so future drift fails tests
Co-authored-by: Cursor <cursoragent@cursor.com>
🦋 Changeset detectedLatest commit: cef7b5b The changes in this PR will be included in the next version bump. This PR includes changesets to release 4 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
…equest_id - Wire/SDK color schema is now case-insensitive, matching the client's input validator. Previously a valid uppercase color (e.g. FFC66F) passed input validation on create but threw "Unexpected highlights API response" when the same value came back through HighlightWireSchema. Adds a round-trip test. - Clarify that request_id is a unique per-request id required by the API and does not by itself make caller-level retries idempotent (comment + changeset). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Verified HighlightsClient against the compiled OpenAPI spec. Follow-ups:
- getHighlights: treat a 204 (no highlights for the passage, the common case)
as an empty collection instead of throwing "Unexpected highlights API
response". Adds a regression test.
- createHighlight: normalize color to lowercase before sending; the API's
color pattern is lowercase-only (^[0-9a-f]{6}$), so an uppercase input would
otherwise be rejected server-side.
- Add getRecentColors() for GET /v1/highlights/recent-colors (recent + default
palette colors) and expose it from useHighlights for color pickers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
cameronapak
previously approved these changes
Jul 8, 2026
Collaborator
Author
|
@greptile review |
Addresses two Greptile review comments on PR #279: - Document that the localStorage guard in getAuthToken intentionally forces SSR/Node callers to pass `lat` explicitly; add a test asserting this. - Explain the useHighlights dep-array strategy (keys on option primitives, not object identity). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
cameronapak
approved these changes
Jul 8, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
HighlightsClientwas written against an outdated version of the highlights API and has never worked against the live endpoints (its integration tests weredescribe.skip'd from day one). Every request was rejected, which surfaced as highlights silently failing to fetch/create/delete in theBibleReaderwiring work (YPE-1034).Verified against the current API reference; the live endpoint responds
401 "Missing or invalid Bearer token"to the old request shape.Changes:
Authorization: Bearer <token>header instead of alatquery parameterbible_idfield on the wire for GET/POST/DELETE; the SDK keepsversion_idin its public types (consistent with the rest of the SDK) and maps at the boundary{ request_id: <uuid>, highlight: { bible_id, passage_id, color } }body for idempotent retriesgetHighlightsnow requiresversion_id+passage_id(verse or chapter USFM) anddeleteHighlightrequiresversion_id, matching the API's required parameters;useHighlightssignatures updated accordinglyhighlights.test.ts(10 tests); the shared MSW handlers now enforce the documented contract (401 without Bearer, 400 on wrong params/body) so future drift fails testsNotes for reviewers
highlightspermission need to re-authenticate to get a token withread_highlights/write_highlightsscopes.YouVersionProvider.test.tsx, missing localStorage in jsdom env) and 5 UI tests (bible-reader.test.tsxtheme settings) fail identically onmain.Test plan
pnpm --filter @youversion/platform-core test— 308 passed, including 10 rewritten highlights contract testspnpm --filter @youversion/platform-react-hooks test— only the 4 pre-existingYouVersionProviderfailures remain (identical on main)pnpm typecheck/pnpm lint— cleanhighlightsscope (blocked on a real token; follow-up in YPE-1034 wiring)Made with Cursor
Greptile Summary
This PR fixes
HighlightsClientto match the live YouVersion highlights API contract. Every request was previously rejected with 401 because auth was sent as alatquery parameter instead of anAuthorization: Bearerheader, and field names / request shapes differed from the documented API. The rewrite also un-skips and rewrites the highlights test suite so the MSW handlers now enforce the contract (auth, field names, enveloped POST body), preventing future drift.latquery param;bible_idis used on the wire while the SDK keepsversion_idin public types, with a cleantoHighlight()mapper at the boundary.{ request_id, highlight: { … } }envelope;getHighlightsanddeleteHighlightnow enforce their required parameters; Zod schemas validate all responses; 204 (no highlights) is treated as an empty collection; uppercase hex colors are normalized to lowercase before sending.getRecentColors()added toHighlightsClientand exposed fromuseHighlightsfor color-picker UIs.Confidence Score: 5/5
Safe to merge — all four endpoint contract fixes are correctly implemented and the MSW handlers now enforce them so any future drift will fail CI.
The implementation changes are precise and internally consistent: Bearer auth, wire-field mapping, enveloped POST body, 204 handling, and Zod validation all align with the documented API. The HEX_COLOR_REGEX was updated to case-insensitive, addressing the mismatch flagged in the previous review cycle. Tests cover the happy path, 204 edge case, malformed-response error, and uppercase color normalization. The changeset bump level is worth a second look but does not affect runtime correctness.
.changeset/fix-highlights-api-contract.md — worth a second look on the minor-vs-major bump decision for 2.x packages.
Important Files Changed
Sequence Diagram
%%{init: {'theme': 'neutral'}}%% sequenceDiagram participant App as App / useHighlights participant HC as HighlightsClient participant AC as ApiClient participant API as Highlights API Note over HC: getAuthToken(lat?) → Bearer header App->>HC: "getHighlights({ version_id, passage_id }, lat?)" HC->>HC: validateVersionId / validatePassageId HC->>AC: "get("/v1/highlights", { bible_id, passage_id }, { Authorization: Bearer })" AC->>API: "GET /v1/highlights?bible_id=&passage_id= + Bearer" API-->>AC: "200 { data: [{ bible_id, passage_id, color }] } OR 204" AC-->>HC: raw response HC->>HC: HighlightCollectionWireSchema.safeParse() HC->>HC: data.map(toHighlight) — bible_id → version_id HC-->>App: "Collection<Highlight>" App->>HC: "createHighlight({ version_id, passage_id, color }, lat?)" HC->>HC: validate + color.toLowerCase() HC->>AC: "post("/v1/highlights", { request_id, highlight: { bible_id, passage_id, color } }, headers)" AC->>API: POST /v1/highlights + Bearer API-->>AC: "201 { bible_id, passage_id, color }" AC-->>HC: raw response HC->>HC: HighlightWireSchema.safeParse() → toHighlight() HC-->>App: Highlight App->>HC: "deleteHighlight(passageId, { version_id }, lat?)" HC->>HC: validatePassageId / validateVersionId HC->>AC: "delete("/v1/highlights/{passageId}", { bible_id }, { Authorization: Bearer })" AC->>API: "DELETE /v1/highlights/{passageId}?bible_id= + Bearer" API-->>AC: 204 HC-->>App: void App->>HC: getRecentColors(lat?) HC->>AC: "get("/v1/highlights/recent-colors", undefined, { Authorization: Bearer })" AC->>API: GET /v1/highlights/recent-colors + Bearer API-->>AC: "200 { data: [{ color }] } OR 204" HC->>HC: RecentHighlightColorsWireSchema.safeParse() HC-->>App: string[]%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% sequenceDiagram participant App as App / useHighlights participant HC as HighlightsClient participant AC as ApiClient participant API as Highlights API Note over HC: getAuthToken(lat?) → Bearer header App->>HC: "getHighlights({ version_id, passage_id }, lat?)" HC->>HC: validateVersionId / validatePassageId HC->>AC: "get("/v1/highlights", { bible_id, passage_id }, { Authorization: Bearer })" AC->>API: "GET /v1/highlights?bible_id=&passage_id= + Bearer" API-->>AC: "200 { data: [{ bible_id, passage_id, color }] } OR 204" AC-->>HC: raw response HC->>HC: HighlightCollectionWireSchema.safeParse() HC->>HC: data.map(toHighlight) — bible_id → version_id HC-->>App: "Collection<Highlight>" App->>HC: "createHighlight({ version_id, passage_id, color }, lat?)" HC->>HC: validate + color.toLowerCase() HC->>AC: "post("/v1/highlights", { request_id, highlight: { bible_id, passage_id, color } }, headers)" AC->>API: POST /v1/highlights + Bearer API-->>AC: "201 { bible_id, passage_id, color }" AC-->>HC: raw response HC->>HC: HighlightWireSchema.safeParse() → toHighlight() HC-->>App: Highlight App->>HC: "deleteHighlight(passageId, { version_id }, lat?)" HC->>HC: validatePassageId / validateVersionId HC->>AC: "delete("/v1/highlights/{passageId}", { bible_id }, { Authorization: Bearer })" AC->>API: "DELETE /v1/highlights/{passageId}?bible_id= + Bearer" API-->>AC: 204 HC-->>App: void App->>HC: getRecentColors(lat?) HC->>AC: "get("/v1/highlights/recent-colors", undefined, { Authorization: Bearer })" AC->>API: GET /v1/highlights/recent-colors + Bearer API-->>AC: "200 { data: [{ color }] } OR 204" HC->>HC: RecentHighlightColorsWireSchema.safeParse() HC-->>App: string[]Comments Outside Diff (2)
packages/core/src/highlights.ts, line 59-71 (link)localStorageguard makes the auth fallback silently different across environments. Whentypeof localStorage === 'undefined'(Node.js test environment, SSR),getAuthToken(undefined)returnsnulland immediately throws. That's the intended path for unauthenticated callers. However, the guard also means that any call inside a server-side context that should find a token inYouVersionPlatformConfigurationwill always throw, even if the platform configuration has been populated another way. Worth a comment that makes this intent explicit, and a test confirming SSR callers must passlatexplicitly.Prompt To Fix With AI
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
packages/hooks/src/useHighlights.ts, line 45-51 (link)optionsobject identity is unstable if passed as an inline literal. The factory() => highlightsClient.getHighlights(options)captures the fulloptionsreference, but the dependency array correctly uses only the primitivesoptions.version_idandoptions.passage_id. This means the closure may hold a stale object reference while the primitives stay stable — in practice this is fine here, but callers who add fields toGetHighlightsOptionslater might be surprised. A quick clarifying comment on the dep-array strategy would help future maintainers.Prompt To Fix With AI
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Reviews (5): Last reviewed commit: "docs(highlights): clarify auth fallback ..." | Re-trigger Greptile