Skip to content
Open
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/content-resource-sync.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@quranjs/api": minor
---

Add content resource sync and snapshot SDK helpers.
75 changes: 69 additions & 6 deletions apps/docs/content/docs/resources.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

<auto-type-table
path="../../../../packages/api/src/types/api/Resources.ts"
name="ContentSyncResponse"
/>

### ContentResourceSnapshot Type

<auto-type-table
path="../../../../packages/api/src/types/api/Resources.ts"
name="ContentResourceSnapshot"
/>

## Recitations

### Get All Recitations
Expand Down Expand Up @@ -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);
```
Expand All @@ -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`,
);
});
```

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
64 changes: 64 additions & 0 deletions packages/api/mocks/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
25 changes: 25 additions & 0 deletions packages/api/src/generated/specs/operation-catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions packages/api/src/runtime/create-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import type { AuthService } from "@/generated/public-contracts";
import type {
ApiParams,
ChapterId,
ContentSyncOptions,
ContentSyncResourceGroup,
HizbNumber,
HTTPMethod,
JuzNumber,
Expand Down Expand Up @@ -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),
},
Expand Down Expand Up @@ -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) =>
Expand Down
41 changes: 41 additions & 0 deletions packages/api/src/sdk/resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ import type {
BaseApiParams,
ChapterInfoResource,
ChapterReciterResource,
ContentResourceSnapshot,
ContentResourceSnapshotOptions,
ContentSyncOptions,
ContentSyncResourceGroup,
ContentSyncResponse,
LanguageResource,
QuranFetchClient,
RecitationInfoResource,
Expand Down Expand Up @@ -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<TData extends Record<string, unknown> = Record<string, unknown>>(
options?: ContentSyncOptions,
): Promise<ContentSyncResponse<TData>> {
return this.fetcher.fetch<ContentSyncResponse<TData>>(
"/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<string, unknown> = Record<string, unknown>,
>(
resourceGroup: ContentSyncResourceGroup,
id: string | number,
options?: ContentResourceSnapshotOptions,
): Promise<ContentResourceSnapshot<TRecord>> {
return this.fetcher.fetch<ContentResourceSnapshot<TRecord>>(
`/content/api/v4/resources/snapshots/${resourceGroup}/${id}`,
options,
);
}
}
71 changes: 71 additions & 0 deletions packages/api/src/types/api/Resources.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { ApiParams } from "../BaseApiParams";
import type { TranslatedName } from "./TranslatedName";

export interface RecitationResource {
Expand Down Expand Up @@ -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<string, unknown> = Record<string, unknown>,
> {
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<string, unknown> = Record<string, unknown>,
> {
sync: {
syncUntilSequence: number;
hasMore: boolean;
nextPageUrl: string | null;
nextSyncToken: string | null;
mutations: ContentSyncMutation<TData>[];
};
}

export interface ContentResourceSnapshot<
TRecord extends Record<string, unknown> = Record<string, unknown>,
> {
resourceGroup: ContentSyncResourceGroup;
resourceId: number;
resourceContentId: number | null;
schemaVersion: number;
syncSequence: number;
records: TRecord[];
}
Loading
Loading