Coverage for youversion/core/http_client.py: 100%

320 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-06-26 11:31 +0100

1"""HTTP client for YouVersion API operations.""" 

2 

3from typing import Any 

4 

5import httpx 

6 

7from ..config import Config 

8from .interfaces import IHttpClient 

9 

10 

11class HttpClient(IHttpClient): 

12 """Handles HTTP operations for YouVersion API.""" 

13 

14 def __init__(self, client: httpx.AsyncClient, user_id: int | None = None): 

15 """Initialize HTTP client. 

16 

17 Args: 

18 client: Authenticated httpx.AsyncClient 

19 user_id: Authenticated user ID 

20 """ 

21 self._client = client 

22 self._user_id = user_id 

23 self._public_client: httpx.AsyncClient | None = None 

24 

25 def _ensure_public_client(self) -> httpx.AsyncClient: 

26 """Return an httpx client without Authorization (public endpoints).""" 

27 if self._public_client is None: 

28 self._public_client = httpx.AsyncClient( 

29 headers=dict(Config.DEFAULT_HEADERS), 

30 timeout=Config.HTTP_TIMEOUT, 

31 ) 

32 return self._public_client 

33 

34 @staticmethod 

35 def _unwrap_envelope(data: dict[str, Any]) -> dict[str, Any]: 

36 if "response" in data: 

37 return data.get("response", {}).get("data", {}) 

38 return data 

39 

40 async def get(self, url: str, **kwargs) -> dict[str, Any]: 

41 """Perform GET request. 

42 

43 Args: 

44 url: URL to request 

45 **kwargs: Additional arguments for the request 

46 

47 Returns: 

48 JSON response as dictionary 

49 """ 

50 # Always merge DEFAULT_HEADERS with any headers passed in kwargs 

51 headers = {**Config.DEFAULT_HEADERS, **kwargs.get("headers", {})} 

52 kwargs["headers"] = headers 

53 response = await self._client.get(url, **kwargs) 

54 try: 

55 data = response.json() 

56 # Support mocked AsyncMock json() returning a coroutine 

57 if hasattr(data, "__await__"): 

58 data = await data 

59 return self._unwrap_envelope(data) 

60 except ValueError: 

61 # Re-raise to allow callers/tests to handle JSON parse errors 

62 raise 

63 

64 async def get_public(self, url: str, **kwargs) -> dict[str, Any]: 

65 """Perform GET without Authorization (endpoints that reject Bearer tokens). 

66 

67 Args: 

68 url: URL to request 

69 **kwargs: Additional arguments for the request 

70 

71 Returns: 

72 JSON response data from the API envelope 

73 """ 

74 headers = {**Config.DEFAULT_HEADERS, **kwargs.get("headers", {})} 

75 kwargs["headers"] = headers 

76 response = await self._ensure_public_client().get(url, **kwargs) 

77 try: 

78 data = response.json() 

79 if hasattr(data, "__await__"): 

80 data = await data 

81 return self._unwrap_envelope(data) 

82 except ValueError: 

83 raise 

84 

85 async def post(self, url: str, **kwargs) -> dict[str, Any]: 

86 """Perform POST request. 

87 

88 Args: 

89 url: URL to request 

90 **kwargs: Additional arguments for the request 

91 

92 Returns: 

93 JSON response as dictionary 

94 """ 

95 # Always merge DEFAULT_HEADERS with any headers passed in kwargs 

96 headers = {**Config.DEFAULT_HEADERS, **kwargs.get("headers", {})} 

97 kwargs["headers"] = headers 

98 response = await self._client.post(url, **kwargs) 

99 try: 

100 data = response.json() 

101 if hasattr(data, "__await__"): 

102 data = await data 

103 if hasattr(data, "__await__"): 

104 data = await data 

105 return self._unwrap_envelope(data) 

106 except ValueError: 

107 # Re-raise to allow callers/tests to handle JSON parse errors 

108 raise 

109 

110 async def close(self) -> None: 

111 """Close the HTTP client.""" 

112 if self._public_client: 

113 await self._public_client.aclose() 

114 if self._client: 

115 await self._client.aclose() 

116 

117 # Legacy methods for backward compatibility 

118 async def get_cards(self, page: int = 1, kind: str = "") -> dict[str, Any]: 

119 """Get cards data from moments endpoint. 

120 

121 Args: 

122 page: Page number 

123 kind: Kind of data to retrieve 

124 

125 Returns: 

126 Cards data as dictionary 

127 """ 

128 url = f"{Config.MOMENTS_API_BASE}" f"{Config.MOMENTS_ITEMS_URL}" 

129 # Build params dict, omitting falsy values (False, None, empty strings) 

130 params = {} 

131 if page is not None and page is not False: 

132 params["page"] = page 

133 if kind: # Excludes None, False, and empty strings 

134 params["kind"] = kind 

135 if self._user_id is not None and self._user_id is not False: 

136 params["user_id"] = self._user_id 

137 

138 return await self.get(url, params=params) 

139 

140 async def get_verse_of_the_day(self) -> dict[str, Any]: 

141 """Get verse of the day data. 

142 

143 Returns: 

144 Verse of the day data as dictionary 

145 """ 

146 

147 return await self.get(Config.VOTD_URL) 

148 

149 # Bible API methods 

150 async def get_bible_configuration(self) -> dict[str, Any]: 

151 """Get Bible configuration. 

152 

153 Returns: 

154 Bible configuration data 

155 """ 

156 url = f"{Config.BIBLE_API_BASE}{Config.BIBLE_CONFIGURATION_URL}" 

157 return await self.get(url) 

158 

159 async def get_bible_versions( 

160 self, language_tag: str = "eng", version_type: str = "all" 

161 ) -> dict[str, Any]: 

162 """Get Bible versions for a language. 

163 

164 Args: 

165 language_tag: Language tag (e.g., 'eng', 'spa') 

166 version_type: Type of versions ('all', 'text', 'audio') 

167 

168 Returns: 

169 Bible versions data 

170 """ 

171 url = f"{Config.BIBLE_API_BASE}{Config.BIBLE_VERSIONS_URL}" 

172 params = {"language_tag": language_tag, "type": version_type} 

173 return await self.get(url, params=params) 

174 

175 async def get_bible_version(self, version_id: int) -> dict[str, Any]: 

176 """Get specific Bible version details. 

177 

178 Args: 

179 version_id: Version ID 

180 

181 Returns: 

182 Bible version data 

183 """ 

184 url = f"{Config.BIBLE_API_BASE}{Config.BIBLE_VERSION_URL}" 

185 params = {"id": version_id} 

186 return await self.get(url, params=params) 

187 

188 async def get_bible_chapter( 

189 self, version_id: int, reference: str 

190 ) -> dict[str, Any]: 

191 """Get Bible chapter content. 

192 

193 Args: 

194 version_id: Version ID 

195 reference: USFM reference (e.g., 'GEN.1') 

196 

197 Returns: 

198 Chapter content data 

199 """ 

200 url = f"{Config.BIBLE_API_BASE}{Config.BIBLE_CHAPTER_URL}" 

201 params = {"id": version_id, "reference": reference} 

202 return await self.get_public(url, params=params) 

203 

204 async def get_recommended_languages(self, country: str = "US") -> dict[str, Any]: 

205 """Get recommended languages for a country. 

206 

207 Args: 

208 country: Country code (e.g., 'US', 'CA') 

209 

210 Returns: 

211 Recommended languages data 

212 """ 

213 url = f"{Config.BIBLE_API_BASE}{Config.BIBLE_RECOMMENDED_LANGUAGES_URL}" 

214 params = {"country": country} 

215 return await self.get(url, params=params) 

216 

217 # Audio Bible API methods 

218 async def get_audio_chapter( 

219 self, version_id: int, reference: str 

220 ) -> dict[str, Any]: 

221 """Get audio chapter information. 

222 

223 Args: 

224 version_id: Audio version ID 

225 reference: USFM reference (e.g., 'GEN.1') 

226 

227 Returns: 

228 Audio chapter data 

229 """ 

230 url = f"{Config.AUDIO_BIBLE_API_BASE}{Config.AUDIO_CHAPTER_URL}" 

231 params = {"version_id": version_id, "reference": reference} 

232 return await self.get_public(url, params=params) 

233 

234 async def get_audio_version(self, audio_id: int) -> dict[str, Any]: 

235 """Get audio version details. 

236 

237 Args: 

238 audio_id: Audio version ID 

239 

240 Returns: 

241 Audio version data 

242 """ 

243 url = f"{Config.AUDIO_BIBLE_API_BASE}{Config.AUDIO_VIEW_URL}" 

244 params = {"id": audio_id} 

245 return await self.get(url, params=params) 

246 

247 # Search API methods 

248 async def search_bible( 

249 self, 

250 query: str, 

251 version_id: int | None = None, 

252 book: str | None = None, 

253 page: int = 1, 

254 ) -> dict[str, Any]: 

255 """Search Bible text. 

256 

257 Args: 

258 query: Search query 

259 version_id: Version ID (optional) 

260 book: Book filter (optional) 

261 page: Page number 

262 

263 Returns: 

264 Search results data 

265 """ 

266 url = f"{Config.SEARCH_API_BASE}{Config.SEARCH_BIBLE_URL}" 

267 params = {"query": query, "page": page} 

268 if version_id: 

269 params["version_id"] = version_id 

270 if book: 

271 params["book"] = book 

272 return await self.get(url, params=params) 

273 

274 async def search_plans( 

275 self, query: str, language_tag: str = "eng", page: int = 1 

276 ) -> dict[str, Any]: 

277 """Search reading plans. 

278 

279 Args: 

280 query: Search query 

281 language_tag: Language tag 

282 page: Page number 

283 

284 Returns: 

285 Plan search results data 

286 """ 

287 url = f"{Config.SEARCH_API_BASE}{Config.SEARCH_PLANS_URL}" 

288 params = {"query": query, "language_tag": language_tag, "page": page} 

289 return await self.get(url, params=params) 

290 

291 async def search_users( 

292 self, query: str, language_tag: str = "eng", page: int = 1 

293 ) -> dict[str, Any]: 

294 """Search users. 

295 

296 Args: 

297 query: Search query 

298 language_tag: Language tag 

299 page: Page number 

300 

301 Returns: 

302 User search results data 

303 """ 

304 url = f"{Config.SEARCH_API_BASE}{Config.SEARCH_USERS_URL}" 

305 params = {"query": query, "language_tag": language_tag, "page": page} 

306 return await self.get(url, params=params) 

307 

308 # Videos API methods 

309 async def get_videos(self, language_tag: str = "eng") -> dict[str, Any]: 

310 """Get videos list. 

311 

312 Args: 

313 language_tag: Language tag 

314 

315 Returns: 

316 Videos data 

317 """ 

318 url = f"{Config.SEARCH_API_BASE}{Config.SEARCH_VIDEOS_URL}" 

319 params = {"language_tag": language_tag} 

320 return await self.get(url, params=params) 

321 

322 async def get_video_details(self, video_id: int) -> dict[str, Any]: 

323 """Get video details. 

324 

325 Args: 

326 video_id: Video ID 

327 

328 Returns: 

329 Video details data 

330 """ 

331 url = f"{Config.VIDEOS_API_BASE}{Config.VIDEOS_VIEW_URL}" 

332 params = {"id": video_id} 

333 return await self.get(url, params=params) 

334 

335 # Images API methods 

336 async def get_images( 

337 self, reference: str, language_tag: str = "eng", page: int = 1 

338 ) -> dict[str, Any]: 

339 """Get images for a reference. 

340 

341 Args: 

342 reference: USFM reference 

343 language_tag: Language tag 

344 page: Page number 

345 

346 Returns: 

347 Images data 

348 """ 

349 url = f"{Config.IMAGES_API_BASE}{Config.IMAGES_ITEMS_URL}" 

350 params = {"reference": reference, "language_tag": language_tag, "page": page} 

351 return await self.get(url, params=params) 

352 

353 async def get_image_upload_url(self) -> dict[str, Any]: 

354 """Get image upload URL and parameters. 

355 

356 Returns: 

357 Upload URL data 

358 """ 

359 url = f"{Config.IMAGES_API_BASE}{Config.IMAGES_UPLOAD_URL}" 

360 return await self.get(url) 

361 

362 # Events API methods 

363 async def search_events( 

364 self, 

365 query: str, 

366 latitude: float | None = None, 

367 longitude: float | None = None, 

368 page: int = 1, 

369 ) -> dict[str, Any]: 

370 """Search events. 

371 

372 Args: 

373 query: Search query 

374 latitude: Latitude (optional) 

375 longitude: Longitude (optional) 

376 page: Page number 

377 

378 Returns: 

379 Event search results data 

380 """ 

381 url = f"{Config.EVENTS_API_BASE}{Config.EVENTS_SEARCH_URL}" 

382 params = {"query": query, "page": page} 

383 if latitude: 

384 params["latitude"] = latitude 

385 if longitude: 

386 params["longitude"] = longitude 

387 return await self.get(url, params=params) 

388 

389 async def get_event_details(self, event_id: int) -> dict[str, Any]: 

390 """Get event details. 

391 

392 Args: 

393 event_id: Event ID 

394 

395 Returns: 

396 Event details data 

397 """ 

398 url = f"{Config.EVENTS_API_BASE}{Config.EVENTS_VIEW_URL}" 

399 params = {"id": event_id} 

400 return await self.get(url, params=params) 

401 

402 async def get_saved_events(self, page: int = 1) -> dict[str, Any]: 

403 """Get saved events. 

404 

405 Args: 

406 page: Page number 

407 

408 Returns: 

409 Saved events data 

410 """ 

411 url = f"{Config.EVENTS_API_BASE}{Config.EVENTS_SAVED_ITEMS_URL}" 

412 params = {"page": page} 

413 return await self.get(url, params=params) 

414 

415 async def save_event( 

416 self, event_id: int, comments: dict[str, Any] | None = None 

417 ) -> dict[str, Any]: 

418 """Save event. 

419 

420 Args: 

421 event_id: Event ID 

422 comments: Comments (optional) 

423 

424 Returns: 

425 Save result data 

426 """ 

427 url = f"{Config.EVENTS_API_BASE}{Config.EVENTS_SAVE_URL}" 

428 data = {"id": event_id} 

429 if comments: 

430 data["comments"] = comments 

431 return await self.post(url, json=data) 

432 

433 async def delete_saved_event(self, event_id: int) -> dict[str, Any]: 

434 """Delete saved event. 

435 

436 Args: 

437 event_id: Event ID 

438 

439 Returns: 

440 Delete result data 

441 """ 

442 url = f"{Config.EVENTS_API_BASE}{Config.EVENTS_DELETE_SAVED_URL}" 

443 return await self.post(url, json={"id": event_id}) 

444 

445 async def get_all_saved_event_ids(self) -> dict[str, Any]: 

446 """Get all saved event IDs. 

447 

448 Returns: 

449 All saved event IDs data 

450 """ 

451 url = f"{Config.EVENTS_API_BASE}{Config.EVENTS_SAVED_ALL_ITEMS_URL}" 

452 return await self.get(url) 

453 

454 async def get_event_configuration(self) -> dict[str, Any]: 

455 """Get event configuration. 

456 

457 Returns: 

458 Event configuration data 

459 """ 

460 url = f"{Config.EVENTS_API_BASE}{Config.EVENTS_CONFIGURATION_URL}" 

461 return await self.get(url) 

462 

463 # Moments API methods 

464 async def get_moments( 

465 self, 

466 page: int = 1, 

467 user_id: int | None = None, 

468 kind: str | None = None, 

469 version_id: int | None = None, 

470 usfm: str | None = None, 

471 ) -> dict[str, Any]: 

472 """Get moments list. 

473 

474 Args: 

475 page: Page number 

476 user_id: User ID (optional) 

477 kind: Kind of moment (optional) 

478 version_id: Bible version ID (optional) 

479 usfm: USFM reference (optional) 

480 

481 Returns: 

482 Moments data 

483 """ 

484 url = f"{Config.MOMENTS_API_BASE}{Config.MOMENTS_ITEMS_URL}" 

485 params = {"page": page} 

486 if user_id: 

487 params["user_id"] = user_id 

488 if kind: 

489 params["kind"] = kind 

490 if version_id: 

491 params["version_id"] = version_id 

492 if usfm: 

493 params["usfm"] = usfm 

494 return await self.get(url, params=params) 

495 

496 async def get_moment_details(self, moment_id: int) -> dict[str, Any]: 

497 """Get moment details. 

498 

499 Args: 

500 moment_id: Moment ID 

501 

502 Returns: 

503 Moment details data 

504 """ 

505 url = f"{Config.MOMENTS_API_BASE}{Config.MOMENTS_VIEW_URL}" 

506 params = {"id": moment_id} 

507 return await self.get(url, params=params) 

508 

509 async def create_moment(self, data: dict[str, Any]) -> dict[str, Any]: 

510 """Create a new moment. 

511 

512 Args: 

513 data: Moment data 

514 

515 Returns: 

516 Created moment data 

517 """ 

518 url = f"{Config.MOMENTS_API_BASE}{Config.MOMENTS_CREATE_URL}" 

519 return await self.post(url, json=data) 

520 

521 async def update_moment(self, data: dict[str, Any]) -> dict[str, Any]: 

522 """Update an existing moment. 

523 

524 Args: 

525 data: Moment data 

526 

527 Returns: 

528 Updated moment data 

529 """ 

530 url = f"{Config.MOMENTS_API_BASE}{Config.MOMENTS_UPDATE_URL}" 

531 return await self.post(url, json=data) 

532 

533 async def delete_moment(self, moment_id: int) -> dict[str, Any]: 

534 """Delete a moment. 

535 

536 Args: 

537 moment_id: Moment ID 

538 

539 Returns: 

540 Delete result data 

541 """ 

542 url = f"{Config.MOMENTS_API_BASE}{Config.MOMENTS_DELETE_URL}" 

543 data = {"id": moment_id} 

544 return await self.post(url, json=data) 

545 

546 async def get_moment_colors(self) -> dict[str, Any]: 

547 """Get available highlight colors. 

548 

549 Returns: 

550 Colors data 

551 """ 

552 url = f"{Config.MOMENTS_API_BASE}{Config.MOMENTS_COLORS_URL}" 

553 return await self.get(url) 

554 

555 async def get_moment_labels(self) -> dict[str, Any]: 

556 """Get moment labels. 

557 

558 Returns: 

559 Labels data 

560 """ 

561 url = f"{Config.MOMENTS_API_BASE}{Config.MOMENTS_LABELS_URL}" 

562 return await self.get(url) 

563 

564 async def get_verse_colors(self, usfm: str, version_id: int) -> dict[str, Any]: 

565 """Get verse highlight colors. 

566 

567 Args: 

568 usfm: USFM reference 

569 version_id: Bible version ID 

570 

571 Returns: 

572 Verse colors data 

573 """ 

574 url = f"{Config.MOMENTS_API_BASE}{Config.MOMENTS_VERSE_COLORS_URL}" 

575 params = {"usfm": usfm, "version_id": version_id} 

576 return await self.get(url, params=params) 

577 

578 async def hide_verse_colors(self, data: dict[str, Any]) -> dict[str, Any]: 

579 """Hide verse highlight colors. 

580 

581 Args: 

582 data: Hide colors data 

583 

584 Returns: 

585 Hide result data 

586 """ 

587 url = f"{Config.MOMENTS_API_BASE}{Config.MOMENTS_HIDE_VERSE_COLORS_URL}" 

588 return await self.post(url, json=data) 

589 

590 async def get_moments_configuration(self) -> dict[str, Any]: 

591 """Get moments configuration. 

592 

593 Returns: 

594 Moments configuration data 

595 """ 

596 url = f"{Config.MOMENTS_API_BASE}{Config.MOMENTS_CONFIGURATION_URL}" 

597 return await self.get_public(url) 

598 

599 # Comments API methods 

600 async def create_comment(self, moment_id: int, comment: str) -> dict[str, Any]: 

601 """Create a comment on a moment. 

602 

603 Args: 

604 moment_id: Moment ID 

605 comment: Comment text 

606 

607 Returns: 

608 Created comment data 

609 """ 

610 url = f"{Config.MOMENTS_API_BASE}{Config.COMMENTS_CREATE_URL}" 

611 data = {"moment_id": moment_id, "comment": comment} 

612 return await self.post(url, json=data) 

613 

614 async def delete_comment(self, comment_id: int) -> dict[str, Any]: 

615 """Delete a comment. 

616 

617 Args: 

618 comment_id: Comment ID 

619 

620 Returns: 

621 Delete result data 

622 """ 

623 url = f"{Config.MOMENTS_API_BASE}{Config.COMMENTS_DELETE_URL}" 

624 data = {"id": comment_id} 

625 return await self.post(url, json=data) 

626 

627 # Likes API methods 

628 async def like_moment(self, moment_id: int) -> dict[str, Any]: 

629 """Like a moment. 

630 

631 Args: 

632 moment_id: Moment ID 

633 

634 Returns: 

635 Like result data 

636 """ 

637 url = f"{Config.MOMENTS_API_BASE}{Config.LIKES_CREATE_URL}" 

638 data = {"moment_id": moment_id} 

639 return await self.post(url, json=data) 

640 

641 async def unlike_moment(self, moment_id: int) -> dict[str, Any]: 

642 """Unlike a moment. 

643 

644 Args: 

645 moment_id: Moment ID 

646 

647 Returns: 

648 Unlike result data 

649 """ 

650 url = f"{Config.MOMENTS_API_BASE}{Config.LIKES_DELETE_URL}" 

651 data = {"moment_id": moment_id} 

652 return await self.post(url, json=data) 

653 

654 # Messaging API methods 

655 async def register_device( 

656 self, 

657 device_id: str, 

658 device_type: str = "android", 

659 user_id: int | None = None, 

660 old_device_id: str | None = None, 

661 tags: str | None = None, 

662 ) -> dict[str, Any]: 

663 """Register device for push notifications. 

664 

665 Args: 

666 device_id: Device ID 

667 device_type: Device type 

668 user_id: User ID (optional) 

669 old_device_id: Previous device ID (optional) 

670 tags: Device tags (optional) 

671 

672 Returns: 

673 Registration result data 

674 """ 

675 url = f"{Config.MOMENTS_API_BASE}{Config.MESSAGING_REGISTER_URL}" 

676 data = {"id": device_id, "type": device_type} 

677 if user_id: 

678 data["user_id"] = user_id 

679 if old_device_id: 

680 data["old_id"] = old_device_id 

681 if tags: 

682 data["tags"] = tags 

683 return await self.post(url, json=data) 

684 

685 async def unregister_device(self, device_id: str) -> dict[str, Any]: 

686 """Unregister device from push notifications. 

687 

688 Args: 

689 device_id: Device ID 

690 

691 Returns: 

692 Unregistration result data 

693 """ 

694 url = f"{Config.MOMENTS_API_BASE}{Config.MESSAGING_UNREGISTER_URL}" 

695 data = {"id": device_id} 

696 return await self.post(url, json=data) 

697 

698 # Themes API methods 

699 async def get_themes( 

700 self, page: int = 1, language_tag: str = "en" 

701 ) -> dict[str, Any]: 

702 """Get available themes. 

703 

704 Args: 

705 page: Page number 

706 language_tag: Language tag 

707 

708 Returns: 

709 Themes data 

710 """ 

711 url = f"{Config.THEMES_API_BASE}{Config.THEMES_ITEMS_URL}" 

712 params = {"page": page, "language_tag": language_tag} 

713 return await self.get(url, params=params) 

714 

715 async def add_theme( 

716 self, 

717 theme_id: int, 

718 available_locales: list[str], 

719 colors: dict[str, Any], 

720 cta_urls: dict[str, Any], 

721 msgid_suffix: str, 

722 version_ids: dict[str, int], 

723 ) -> dict[str, Any]: 

724 """Add a theme to user's collection. 

725 

726 Args: 

727 theme_id: Theme ID 

728 available_locales: List of available locale codes 

729 colors: Theme colors dictionary 

730 cta_urls: Call-to-action URLs dictionary 

731 msgid_suffix: Message ID suffix 

732 version_ids: List of version IDs 

733 

734 Returns: 

735 Add result data 

736 """ 

737 url = f"{Config.THEMES_API_BASE}{Config.THEMES_ADD_URL}" 

738 data = { 

739 "id": theme_id, 

740 "available_locales": available_locales, 

741 "colors": colors, 

742 "cta_urls": cta_urls, 

743 "msgid_suffix": msgid_suffix, 

744 "version_ids": version_ids, 

745 } 

746 

747 return await self.post(url, json=data) 

748 

749 async def remove_theme(self, theme_id: int) -> dict[str, Any]: 

750 """Remove a theme from user's collection. 

751 

752 Args: 

753 theme_id: Theme ID 

754 

755 Returns: 

756 Remove result data 

757 """ 

758 url = f"{Config.THEMES_API_BASE}{Config.THEMES_REMOVE_URL}" 

759 data = {"id": theme_id} 

760 return await self.post(url, json=data) 

761 

762 async def set_theme( 

763 self, theme_id: int, previous_theme_id: int | None = None 

764 ) -> dict[str, Any]: 

765 """Set active theme. 

766 

767 Args: 

768 theme_id: Theme ID 

769 previous_theme_id: Previous theme ID (optional) 

770 

771 Returns: 

772 Set result data 

773 """ 

774 url = f"{Config.THEMES_API_BASE}{Config.THEMES_SET_URL}" 

775 data = {"id": theme_id} 

776 if previous_theme_id: 

777 data["previous_id"] = previous_theme_id 

778 return await self.post(url, json=data) 

779 

780 async def get_theme_description( 

781 self, theme_id: int, language_tag: str = "eng" 

782 ) -> dict[str, Any]: 

783 """Get theme description. 

784 

785 Args: 

786 theme_id: Theme ID 

787 language_tag: Language tag 

788 

789 Returns: 

790 Theme description data 

791 """ 

792 url = f"{Config.THEMES_API_BASE}{Config.THEMES_DESCRIPTION_URL}" 

793 params = {"id": theme_id, "language_tag": language_tag} 

794 return await self.get(url, params=params) 

795 

796 # Friendships API methods 

797 async def send_friend_request(self, user_id: int) -> dict[str, Any]: 

798 """Send a friend request to a user. 

799 

800 Args: 

801 user_id: User ID to send friend request to 

802 

803 Returns: 

804 Friend request response data 

805 """ 

806 url = f"{Config.FRIENDSHIPS_API_BASE}{Config.FRIENDSHIPS_OFFER_URL}" 

807 data = {"user_id": user_id} 

808 return await self.post(url, json=data) 

809 

810 # Friends API methods 

811 async def get_friends(self, page: int = 1) -> dict[str, Any]: 

812 """Get the authenticated user's friends list. 

813 

814 Args: 

815 page: Page number for pagination. 

816 

817 Returns: 

818 Friends list data including ``users`` and ``next_page``. 

819 """ 

820 url = f"{Config.FRIENDS_API_BASE}{Config.FRIENDS_ITEMS_URL}" 

821 return await self.get(url, params={"page": page}) 

822 

823 async def get_all_friends(self, page: int = 1) -> dict[str, Any]: 

824 """Get all friends including extended friend metadata. 

825 

826 Args: 

827 page: Page number for pagination. 

828 

829 Returns: 

830 All friends data including a ``friends`` collection. 

831 """ 

832 url = f"{Config.FRIENDS_API_BASE}{Config.FRIENDS_ALL_ITEMS_URL}" 

833 return await self.get(url, params={"page": page}) 

834 

835 async def delete_friend(self, user_id: int) -> dict[str, Any]: 

836 """Remove a friend. 

837 

838 Args: 

839 user_id: YouVersion user ID of the friend to remove. 

840 

841 Returns: 

842 Delete operation result data. 

843 """ 

844 url = f"{Config.FRIENDS_API_BASE}{Config.FRIENDS_DELETE_URL}" 

845 return await self.post(url, json={"user_id": user_id}) 

846 

847 # Friendships API methods 

848 async def get_incoming_friend_requests(self, page: int = 1) -> dict[str, Any]: 

849 """Get incoming friend requests. 

850 

851 Args: 

852 page: Page number for pagination. 

853 

854 Returns: 

855 Incoming friend request data. 

856 """ 

857 url = f"{Config.FRIENDSHIPS_API_BASE}{Config.FRIENDSHIPS_INCOMING_URL}" 

858 return await self.get(url, params={"page": page}) 

859 

860 async def get_friend_suggestions( 

861 self, page: int = 1, language_tag: str = "en" 

862 ) -> dict[str, Any]: 

863 """Get friend suggestions. 

864 

865 Args: 

866 page: Page number for pagination. 

867 language_tag: ISO 639-1 locale (e.g. ``en``, not ``eng``). 

868 

869 Returns: 

870 Friend suggestion data. 

871 """ 

872 url = f"{Config.FRIENDSHIPS_API_BASE}{Config.FRIENDSHIPS_SUGGESTIONS_URL}" 

873 return await self.get(url, params={"page": page, "language_tag": language_tag}) 

874 

875 async def accept_friend_request(self, user_id: int) -> dict[str, Any]: 

876 """Accept an incoming friend request. 

877 

878 Args: 

879 user_id: User ID of the requester to accept. 

880 

881 Returns: 

882 Accept operation result data. 

883 """ 

884 url = f"{Config.FRIENDSHIPS_API_BASE}{Config.FRIENDSHIPS_ACCEPT_URL}" 

885 return await self.post(url, json={"user_id": user_id}) 

886 

887 async def decline_friend_request(self, user_id: int) -> dict[str, Any]: 

888 """Decline an incoming friend request. 

889 

890 Args: 

891 user_id: User ID of the requester to decline. 

892 

893 Returns: 

894 Decline operation result data. 

895 """ 

896 url = f"{Config.FRIENDSHIPS_API_BASE}{Config.FRIENDSHIPS_DECLINE_URL}" 

897 return await self.post(url, json={"user_id": user_id}) 

898 

899 async def dismiss_friend_suggestion(self, user_id: int) -> dict[str, Any]: 

900 """Dismiss a friend suggestion. 

901 

902 Args: 

903 user_id: Suggested user ID to dismiss. 

904 

905 Returns: 

906 Dismiss operation result data. 

907 """ 

908 url = ( 

909 f"{Config.FRIENDSHIPS_API_BASE}{Config.FRIENDSHIPS_DISMISS_SUGGESTION_URL}" 

910 ) 

911 return await self.post(url, json={"user_id": user_id}) 

912 

913 async def get_facebook_friends(self, page: int = 1) -> dict[str, Any]: 

914 """Get Facebook-linked friend suggestions. 

915 

916 Args: 

917 page: Page number for pagination. 

918 

919 Returns: 

920 Facebook friends data. 

921 """ 

922 url = f"{Config.FRIENDSHIPS_API_BASE}{Config.FRIENDSHIPS_FACEBOOK_FRIENDS_URL}" 

923 return await self.get(url, params={"page": page}) 

924 

925 async def sync_friendship_contacts( 

926 self, contacts: list[dict[str, Any]] 

927 ) -> dict[str, Any]: 

928 """Upload device contacts for friend matching. 

929 

930 Args: 

931 contacts: Contact records to sync with YouVersion. 

932 

933 Returns: 

934 Contact sync result data. 

935 """ 

936 url = f"{Config.FRIENDSHIPS_API_BASE}{Config.FRIENDSHIPS_CONTACTS_URL}" 

937 return await self.post(url, json={"contacts": contacts}) 

938 

939 # Notifications API methods 

940 async def get_notifications(self, page: int = 1) -> dict[str, Any]: 

941 """Get notification feed items. 

942 

943 Args: 

944 page: Page number for pagination. 

945 

946 Returns: 

947 Notification items and unread counts. 

948 """ 

949 url = f"{Config.NOTIFICATIONS_API_BASE}{Config.NOTIFICATIONS_ITEMS_URL}" 

950 return await self.get(url, params={"page": page}) 

951 

952 async def get_notification_settings(self) -> dict[str, Any]: 

953 """Get notification settings for the authenticated user. 

954 

955 Returns: 

956 Notification settings payload. 

957 """ 

958 url = f"{Config.NOTIFICATIONS_API_BASE}{Config.NOTIFICATIONS_SETTINGS_URL}" 

959 return await self.get(url) 

960 

961 async def update_notification_settings( 

962 self, data: dict[str, Any] 

963 ) -> dict[str, Any]: 

964 """Update notification settings. 

965 

966 Args: 

967 data: Settings fields to update. 

968 

969 Returns: 

970 Updated settings data. 

971 """ 

972 url = ( 

973 f"{Config.NOTIFICATIONS_API_BASE}{Config.NOTIFICATIONS_UPDATE_SETTINGS_URL}" 

974 ) 

975 return await self.post(url, json=data) 

976 

977 async def update_notifications(self, data: dict[str, Any]) -> dict[str, Any]: 

978 """Mark notifications read or update notification state. 

979 

980 Args: 

981 data: Notification update payload. 

982 

983 Returns: 

984 Update result data. 

985 """ 

986 url = f"{Config.NOTIFICATIONS_API_BASE}{Config.NOTIFICATIONS_UPDATE_URL}" 

987 return await self.post(url, json=data) 

988 

989 async def get_votd_notification_settings(self) -> dict[str, Any]: 

990 """Get verse-of-the-day notification settings. 

991 

992 Returns: 

993 VOTD notification settings data. 

994 """ 

995 url = f"{Config.NOTIFICATIONS_API_BASE}{Config.NOTIFICATIONS_VOTD_SETTINGS_URL}" 

996 return await self.get(url) 

997 

998 async def update_votd_notification_settings( 

999 self, data: dict[str, Any] 

1000 ) -> dict[str, Any]: 

1001 """Update verse-of-the-day notification settings. 

1002 

1003 Args: 

1004 data: VOTD settings fields to update. 

1005 

1006 Returns: 

1007 Updated VOTD settings data. 

1008 """ 

1009 url = ( 

1010 f"{Config.NOTIFICATIONS_API_BASE}" 

1011 f"{Config.NOTIFICATIONS_UPDATE_VOTD_SETTINGS_URL}" 

1012 ) 

1013 return await self.post(url, json=data) 

1014 

1015 # Share API methods 

1016 async def invite_by_email(self, data: dict[str, Any]) -> dict[str, Any]: 

1017 """Send a YouVersion invite email. 

1018 

1019 Args: 

1020 data: Invite payload (e.g. email address and message fields). 

1021 

1022 Returns: 

1023 Invite operation result data. 

1024 """ 

1025 url = f"{Config.SHARE_API_BASE}{Config.SHARE_INVITE_EMAIL_URL}" 

1026 return await self.post(url, json=data) 

1027 

1028 async def invite_by_sms(self, data: dict[str, Any]) -> dict[str, Any]: 

1029 """Send a YouVersion invite SMS. 

1030 

1031 Args: 

1032 data: Invite payload (e.g. phone number and message fields). 

1033 

1034 Returns: 

1035 Invite operation result data. 

1036 """ 

1037 url = f"{Config.SHARE_API_BASE}{Config.SHARE_INVITE_SMS_URL}" 

1038 return await self.post(url, json=data) 

1039 

1040 # Additional moments API methods 

1041 async def get_client_side_moments(self, page: int = 1) -> dict[str, Any]: 

1042 """Get client-side moment items. 

1043 

1044 Args: 

1045 page: Page number for pagination. 

1046 

1047 Returns: 

1048 Client-side moments data. 

1049 """ 

1050 url = f"{Config.MOMENTS_API_BASE}{Config.MOMENTS_CLIENT_SIDE_ITEMS_URL}" 

1051 return await self.get(url, params={"page": page}) 

1052 

1053 async def get_moments_votd(self, language_tag: str | None = None) -> dict[str, Any]: 

1054 """Get verse of the day from the moments API. 

1055 

1056 Args: 

1057 language_tag: Optional language tag; omit for default VOTD payload. 

1058 

1059 Returns: 

1060 Verse of the day data from moments service. 

1061 """ 

1062 url = f"{Config.MOMENTS_API_BASE}{Config.MOMENTS_VOTD_URL}" 

1063 params: dict[str, Any] = {} 

1064 if language_tag: 

1065 params["language_tag"] = language_tag 

1066 return await self.get_public(url, params=params or None)