diff --git a/.changeset/content-resource-sync.md b/.changeset/content-resource-sync.md new file mode 100644 index 0000000..7253606 --- /dev/null +++ b/.changeset/content-resource-sync.md @@ -0,0 +1,5 @@ +--- +"@quranjs/api": minor +--- + +Add content resource sync and snapshot SDK helpers. diff --git a/apps/docs/content/docs/resources.mdx b/apps/docs/content/docs/resources.mdx index 8726bc2..87b92f5 100644 --- a/apps/docs/content/docs/resources.mdx +++ b/apps/docs/content/docs/resources.mdx @@ -5,6 +5,67 @@ description: Access metadata about translations, tafsirs, reciters, and language The Resources API provides metadata about translations, tafsirs, reciters, and other Quranic resources. +## Content Resource Sync + +Use content sync when your app needs to cache public resources locally and keep them updated without refetching every resource list. + +### Bootstrap Resources + +```ts +const page = await client.resources.sync({ + bootstrap: true, + resources: "articles:*;tafsirs:151;translations:1,6", + perPage: 100, +}); + +for (const mutation of page.sync.mutations) { + if (mutation.snapshotUrl) { + const snapshot = await client.resources.findSnapshot( + mutation.resourceGroup, + mutation.resourceId, + ); + console.log(snapshot.records); + } +} +``` + +If `page.sync.hasMore` is true, fetch `page.sync.nextPageUrl` through your backend or call `sync` again with the cursor from that URL. Store `page.sync.nextSyncToken` from the final bootstrap page. + +### Poll Incremental Changes + +```ts +const changes = await client.resources.sync({ + resources: "articles:*;tafsirs:151;translations:1,6", + syncToken: savedSyncToken, +}); + +for (const mutation of changes.sync.mutations) { + if ( + mutation.type === "ROW_UPDATE" && + mutation.recordKey !== null && + mutation.data !== null + ) { + updateLocalRow(mutation.recordKey, mutation.data); + } +} +``` + +Use `client.content.v4.resources.sync(...)` and `client.content.v4.resources.findSnapshot(...)` when working through the namespaced v4 client. + +### ContentSyncResponse Type + + + +### ContentResourceSnapshot Type + + + ## Recitations ### Get All Recitations @@ -101,7 +162,7 @@ tafsirs.forEach((t) => { ```ts const info = await client.resources.findTafsirInfo("171"); -console.log(info.name); // "Tafsir Ibn Kathir" +console.log(info.name); // "Tafsir Ibn Kathir" console.log(info.authorName); // "Ibn Kathir" console.log(info.bio); ``` @@ -119,7 +180,9 @@ console.log(info.bio); const languages = await client.resources.findAllLanguages(); languages.forEach((lang) => { - console.log(`${lang.name} (${lang.iso}): ${lang.translationsCount} translations`); + console.log( + `${lang.name} (${lang.iso}): ${lang.translationsCount} translations`, + ); }); ``` @@ -171,8 +234,8 @@ reciters.forEach((r) => { ```ts const styles = await client.resources.findAllRecitationStyles(); -console.log(styles.murattal); // Murattal reciters -console.log(styles.mujawwad); // Mujawwad reciters +console.log(styles.murattal); // Murattal reciters +console.log(styles.mujawwad); // Mujawwad reciters ``` ### RecitationStylesResource Type @@ -188,8 +251,8 @@ console.log(styles.mujawwad); // Mujawwad reciters const media = await client.resources.findVerseMedia(); console.log(media.unicode); // Font URL -console.log(media.image); // Images base URL -console.log(media.audio); // Audio base URL +console.log(media.image); // Images base URL +console.log(media.audio); // Audio base URL ``` ### VerseMediaResource Type diff --git a/packages/api/mocks/handlers.ts b/packages/api/mocks/handlers.ts index f29b051..8fc8f97 100644 --- a/packages/api/mocks/handlers.ts +++ b/packages/api/mocks/handlers.ts @@ -122,6 +122,70 @@ export const handlers = [ }, ), + http.get( + "https://apis.quran.foundation/content/api/v4/resources/sync", + ({ request }) => { + try { + validateAuth(request); + return HttpResponse.json({ + sync: { + sync_until_sequence: 98100, + has_more: false, + next_page_url: null, + next_sync_token: "sync-token-98100", + mutations: [ + { + sequence: 98100, + type: "ROW_UPDATE", + resource_group: "translations", + resource_id: 19, + resource_content_id: 19, + record_type: "translation", + record_key: "85108", + source_record_id: 85108, + changed_at: "2026-05-05T10:00:00Z", + data: { + id: 85108, + verse_key: "26:153", + text: "They said: Thou art but one of the bewitched;", + }, + snapshot_url: null, + unavailable_reason: null, + }, + ], + }, + }); + } catch { + return HttpResponse.text("Unauthorized", { status: 401 }); + } + }, + ), + + http.get( + "https://apis.quran.foundation/content/api/v4/resources/snapshots/:resource_group/:id", + ({ request, params }) => { + try { + validateAuth(request); + return HttpResponse.json({ + resource_group: params.resource_group, + resource_id: Number(params.id), + resource_content_id: Number(params.id), + schema_version: 1, + sync_sequence: 98100, + records: [ + { + id: 85108, + verse_key: "26:153", + text: "They said: Thou art but one of the bewitched;", + }, + ], + }); + } catch { + return HttpResponse.text("Unauthorized", { status: 401 }); + } + }, + ), + http.get("https://apis.quran.foundation/v1/search", ({ request }) => { try { validateAuth(request); diff --git a/packages/api/src/generated/specs/operation-catalog.json b/packages/api/src/generated/specs/operation-catalog.json index 4dabfac..0aa3f0d 100644 --- a/packages/api/src/generated/specs/operation-catalog.json +++ b/packages/api/src/generated/specs/operation-catalog.json @@ -250,6 +250,21 @@ "method": "get", "path": "/hadith_references/count_within_range" }, + "listPages": { + "auth": "app", + "method": "get", + "path": "/pages" + }, + "getPagesLookup": { + "auth": "app", + "method": "get", + "path": "/pages/lookup" + }, + "getPagesPageNumber": { + "auth": "app", + "method": "get", + "path": "/pages/{page_number}" + }, "versesByChapterNumber": { "auth": "app", "method": "get", @@ -659,6 +674,16 @@ "auth": "app", "method": "get", "path": "/answers/count_within_range" + }, + "resourcesSync": { + "auth": "app", + "method": "get", + "path": "/resources/sync" + }, + "resourcesSnapshot": { + "auth": "app", + "method": "get", + "path": "/resources/snapshots/{resource_group}/{id}" } }, "service": "content", diff --git a/packages/api/src/runtime/create-client.ts b/packages/api/src/runtime/create-client.ts index 21d583c..3489c45 100644 --- a/packages/api/src/runtime/create-client.ts +++ b/packages/api/src/runtime/create-client.ts @@ -2,6 +2,8 @@ import type { AuthService } from "@/generated/public-contracts"; import type { ApiParams, ChapterId, + ContentSyncOptions, + ContentSyncResourceGroup, HizbNumber, HTTPMethod, JuzNumber, @@ -393,6 +395,11 @@ const createContentFacade = ( }, raw, resources: { + findSnapshot: ( + resourceGroup: ContentSyncResourceGroup, + id: string | number, + query?: ApiParams, + ) => resources.findSnapshot(resourceGroup, id, query), chapterInfos: { list: (query?: ApiParams) => resources.findAllChapterInfos(query), }, @@ -423,6 +430,7 @@ const createContentFacade = ( verseMedia: { list: (query?: ApiParams) => resources.findVerseMedia(query), }, + sync: (query?: ContentSyncOptions) => resources.sync(query), }, verses: { byChapter: (id: ChapterId, query?: ApiParams) => diff --git a/packages/api/src/sdk/resources.ts b/packages/api/src/sdk/resources.ts index 60a7ca5..78b56e0 100644 --- a/packages/api/src/sdk/resources.ts +++ b/packages/api/src/sdk/resources.ts @@ -2,6 +2,11 @@ import type { BaseApiParams, ChapterInfoResource, ChapterReciterResource, + ContentResourceSnapshot, + ContentResourceSnapshotOptions, + ContentSyncOptions, + ContentSyncResourceGroup, + ContentSyncResponse, LanguageResource, QuranFetchClient, RecitationInfoResource, @@ -203,4 +208,40 @@ export class QuranResources { return verseMedia; } + + /** + * Bootstrap or incrementally sync public content resources. + * @param {ContentSyncOptions} options + * @example + * client.resources.sync({ bootstrap: true, resources: "translations:19" }) + */ + async sync = Record>( + options?: ContentSyncOptions, + ): Promise> { + return this.fetcher.fetch>( + "/content/api/v4/resources/sync", + options, + ); + } + + /** + * Fetch the current full snapshot for a syncable public content resource. + * @param {ContentSyncResourceGroup} resourceGroup + * @param {string | number} id + * @param {ContentResourceSnapshotOptions} options + * @example + * client.resources.findSnapshot("translations", 19) + */ + async findSnapshot< + TRecord extends Record = Record, + >( + resourceGroup: ContentSyncResourceGroup, + id: string | number, + options?: ContentResourceSnapshotOptions, + ): Promise> { + return this.fetcher.fetch>( + `/content/api/v4/resources/snapshots/${resourceGroup}/${id}`, + options, + ); + } } diff --git a/packages/api/src/types/api/Resources.ts b/packages/api/src/types/api/Resources.ts index c3de2a0..0ebd5cd 100644 --- a/packages/api/src/types/api/Resources.ts +++ b/packages/api/src/types/api/Resources.ts @@ -1,3 +1,4 @@ +import type { ApiParams } from "../BaseApiParams"; import type { TranslatedName } from "./TranslatedName"; export interface RecitationResource { @@ -79,3 +80,73 @@ export interface ChapterReciterResource { format?: string; filesSize?: number; // in kb } + +export type ContentSyncResourceGroup = + | "articles" + | "recitations" + | "tafsirs" + | "translations"; + +export type ContentSyncMutationType = + | "RESOURCE_CREATE" + | "RESOURCE_UPDATE" + | "RESOURCE_DELETE" + | "ROW_CREATE" + | "ROW_UPDATE" + | "ROW_DELETE" + | "RESOURCE_INVALIDATE"; + +export interface ContentSyncOptions extends ApiParams { + /** Resource filter, e.g. `articles:*;translations:1,6`. */ + resources?: string; + /** Set to true for the initial sync. */ + bootstrap?: boolean; + /** Token returned by the final bootstrap or incremental page. */ + syncToken?: string; + /** Pagination cursor from `nextPageUrl`. */ + cursor?: string; + /** Page size, up to the API maximum. */ + perPage?: number; +} + +export type ContentResourceSnapshotOptions = ApiParams; + +export interface ContentSyncMutation< + TData extends Record = Record, +> { + sequence: number; + type: ContentSyncMutationType; + resourceGroup: ContentSyncResourceGroup; + resourceId: number; + resourceContentId: number | null; + recordType: string | null; + recordKey: string | null; + sourceRecordId: number | null; + changedAt: string; + data: TData | null; + snapshotUrl: string | null; + unavailableReason: string | null; +} + +export interface ContentSyncResponse< + TData extends Record = Record, +> { + sync: { + syncUntilSequence: number; + hasMore: boolean; + nextPageUrl: string | null; + nextSyncToken: string | null; + mutations: ContentSyncMutation[]; + }; +} + +export interface ContentResourceSnapshot< + TRecord extends Record = Record, +> { + resourceGroup: ContentSyncResourceGroup; + resourceId: number; + resourceContentId: number | null; + schemaVersion: number; + syncSequence: number; + records: TRecord[]; +} diff --git a/packages/api/test/resources.test.ts b/packages/api/test/resources.test.ts index 3e06763..d8e85c1 100644 --- a/packages/api/test/resources.test.ts +++ b/packages/api/test/resources.test.ts @@ -1,11 +1,18 @@ +import { http, HttpResponse } from "msw"; import { describe, expect, it } from "vitest"; +import { server } from "../mocks/server"; import { testClient } from "./test-client"; const VALID_RECITATION_ID = "1"; const VALID_TRANSLATION_ID = "1"; const VALID_TAFSIR_ID = "169"; +const expectCapturedUrl = (url: URL | null): URL => { + expect(url).not.toBeNull(); + return url!; +}; + describe("Resources API", () => { describe("findAllChapterInfos()", () => { it("should return an array of chapter infos", async () => { @@ -94,4 +101,144 @@ describe("Resources API", () => { expect(response).toBeDefined(); }); }); + + describe("sync()", () => { + it("serializes bootstrap sync request params", async () => { + let requestUrl: URL | null = null; + + server.use( + http.get( + "https://apis.quran.foundation/content/api/v4/resources/sync", + ({ request }) => { + requestUrl = new URL(request.url); + return HttpResponse.json({ + sync: { + sync_until_sequence: 1, + has_more: false, + next_page_url: null, + next_sync_token: "sync-token-1", + mutations: [], + }, + }); + }, + ), + ); + + await testClient.resources.sync({ + bootstrap: true, + resources: "articles:*;translations:1,6", + perPage: 100, + }); + + const url = expectCapturedUrl(requestUrl); + expect(url.pathname).toBe("/content/api/v4/resources/sync"); + expect(url.searchParams.get("bootstrap")).toBe("true"); + expect(url.searchParams.get("resources")).toBe( + "articles:*;translations:1,6", + ); + expect(url.searchParams.get("per_page")).toBe("100"); + }); + + it("serializes incremental sync request params", async () => { + let requestUrl: URL | null = null; + + server.use( + http.get( + "https://apis.quran.foundation/content/api/v4/resources/sync", + ({ request }) => { + requestUrl = new URL(request.url); + return HttpResponse.json({ + sync: { + sync_until_sequence: 2, + has_more: false, + next_page_url: null, + next_sync_token: "sync-token-2", + mutations: [], + }, + }); + }, + ), + ); + + await testClient.resources.sync({ + resources: "translations:19", + syncToken: "sync-token-1", + perPage: 50, + }); + + const url = expectCapturedUrl(requestUrl); + expect(url.searchParams.get("resources")).toBe("translations:19"); + expect(url.searchParams.get("sync_token")).toBe("sync-token-1"); + expect(url.searchParams.get("per_page")).toBe("50"); + expect(url.searchParams.has("bootstrap")).toBe(false); + }); + + it("camel-cases sync mutation fields", async () => { + const response = await testClient.resources.sync({ + resources: "translations:19", + bootstrap: true, + }); + + expect(response.sync.syncUntilSequence).toBe(98100); + expect(response.sync.nextSyncToken).toBe("sync-token-98100"); + expect(response.sync.hasMore).toBe(false); + + const mutation = response.sync.mutations[0]; + expect(mutation?.resourceGroup).toBe("translations"); + expect(mutation?.resourceContentId).toBe(19); + expect(mutation?.recordType).toBe("translation"); + expect(mutation?.recordKey).toBe("85108"); + expect(mutation?.sourceRecordId).toBe(85108); + expect(mutation?.changedAt).toBe("2026-05-05T10:00:00Z"); + expect(mutation?.snapshotUrl).toBeNull(); + expect(mutation?.unavailableReason).toBeNull(); + expect(mutation?.data?.verseKey).toBe("26:153"); + }); + + it("exposes sync through content.v4.resources", async () => { + const response = await testClient.content.v4.resources.sync({ + resources: "translations:19", + bootstrap: true, + }); + + expect(response.sync.nextSyncToken).toBe("sync-token-98100"); + }); + }); + + describe("findSnapshot()", () => { + it("serializes snapshot path params", async () => { + let requestUrl: URL | null = null; + + server.use( + http.get( + "https://apis.quran.foundation/content/api/v4/resources/snapshots/:resourceGroup/:id", + ({ request, params }) => { + requestUrl = new URL(request.url); + return HttpResponse.json({ + resource_group: params.resourceGroup, + resource_id: Number(params.id), + resource_content_id: Number(params.id), + schema_version: 1, + sync_sequence: 98100, + records: [], + }); + }, + ), + ); + + const snapshot = await testClient.content.v4.resources.findSnapshot( + "translations", + 19, + ); + + const url = expectCapturedUrl(requestUrl); + expect(url.pathname).toBe( + "/content/api/v4/resources/snapshots/translations/19", + ); + expect(snapshot.resourceGroup).toBe("translations"); + expect(snapshot.resourceId).toBe(19); + expect(snapshot.resourceContentId).toBe(19); + expect(snapshot.syncSequence).toBe(98100); + }); + }); });