From 4250b1eab30c5ac062d97983db6d533de39ea863 Mon Sep 17 00:00:00 2001 From: Basit Minhas Date: Mon, 4 May 2026 15:02:27 +0500 Subject: [PATCH 1/2] Add SDK support for content answers --- .changeset/v4-answers-sdk.md | 5 + packages/api/mocks/handlers.ts | 80 +++++++++++++ packages/api/src/lib/url.ts | 1 + packages/api/src/runtime/create-client.ts | 13 +++ .../api/src/runtime/create-public-client.ts | 1 + packages/api/src/sdk/answers.ts | 108 ++++++++++++++++++ packages/api/src/sdk/client.ts | 3 + packages/api/src/types/api/Answer.ts | 35 ++++++ packages/api/src/types/api/index.ts | 1 + packages/api/test/answers.test.ts | 101 ++++++++++++++++ 10 files changed, 348 insertions(+) create mode 100644 .changeset/v4-answers-sdk.md create mode 100644 packages/api/src/sdk/answers.ts create mode 100644 packages/api/src/types/api/Answer.ts create mode 100644 packages/api/test/answers.test.ts diff --git a/.changeset/v4-answers-sdk.md b/.changeset/v4-answers-sdk.md new file mode 100644 index 0000000..3652a84 --- /dev/null +++ b/.changeset/v4-answers-sdk.md @@ -0,0 +1,5 @@ +--- +"@quranjs/api": minor +--- + +Add first-class SDK methods and types for the public Content v4 answers endpoints. diff --git a/packages/api/mocks/handlers.ts b/packages/api/mocks/handlers.ts index f29b051..5cac590 100644 --- a/packages/api/mocks/handlers.ts +++ b/packages/api/mocks/handlers.ts @@ -876,6 +876,86 @@ export const handlers = [ }, ), + http.get( + "https://apis.quran.foundation/content/api/v4/answers/by_ayah/:ayah_key", + ({ params }) => { + return HttpResponse.json({ + questions: [ + { + id: "question-1", + body: "What is the context of this ayah?", + type: "CLARIFICATION", + ranges: [params.ayah_key], + surah: 2, + theme: ["Faith"], + summary: "A short summary", + references: ["Tafsir al-Tabari"], + language: "en", + status: "Published", + answers: [ + { + id: "answer-1", + body: "This ayah is known as Ayat al-Kursi.", + answeredBy: "Scholar", + status: "Published", + language: "en", + }, + ], + }, + ], + totalCount: 1, + }); + }, + ), + + http.get( + "https://apis.quran.foundation/content/api/v4/answers/count_within_range", + () => { + return HttpResponse.json({ + "2:255": { + total: 2, + types: { + CLARIFICATION: 1, + TAFSIR: 1, + }, + }, + "2:256": { + total: 1, + types: { + TAFSIR: 1, + }, + }, + }); + }, + ), + + http.get( + "https://apis.quran.foundation/content/api/v4/answers/:question_id", + ({ params }) => { + return HttpResponse.json({ + id: params.question_id, + body: "What is the context of this ayah?", + type: "CLARIFICATION", + ranges: ["2:255"], + surah: 2, + theme: ["Faith"], + summary: "A short summary", + references: ["Tafsir al-Tabari"], + language: "en", + status: "Published", + answers: [ + { + id: "answer-1", + body: "This ayah is known as Ayat al-Kursi.", + answeredBy: "Scholar", + status: "Published", + language: "en", + }, + ], + }); + }, + ), + http.get( "https://apis.quran.foundation/content/api/v4/resources/verse_media", () => { diff --git a/packages/api/src/lib/url.ts b/packages/api/src/lib/url.ts index 3d16daf..8950c56 100644 --- a/packages/api/src/lib/url.ts +++ b/packages/api/src/lib/url.ts @@ -41,6 +41,7 @@ const fieldsKeySet = new Set([ ]); const preservedKeys = new Set([ "navigationalResultsNumber", + "pageSize", "versesResultsNumber", ]); diff --git a/packages/api/src/runtime/create-client.ts b/packages/api/src/runtime/create-client.ts index 21d583c..14c3b08 100644 --- a/packages/api/src/runtime/create-client.ts +++ b/packages/api/src/runtime/create-client.ts @@ -18,6 +18,7 @@ import { operationCatalog } from "@/generated/contracts"; import { toUserSession } from "@/lib/http-utils"; import { createGeneratedGroups, createRawClient } from "@/lib/runtime-utils"; import { replacePathParams } from "@/lib/url"; +import { QuranAnswers } from "@/sdk/answers"; import { QuranAudio } from "@/sdk/audio"; import { QuranChapters } from "@/sdk/chapters"; import { QuranFetcher } from "@/sdk/fetcher"; @@ -345,6 +346,7 @@ const createOAuth2Facade = ( }; const createContentFacade = ( + answers: QuranAnswers, chapters: QuranChapters, verses: QuranVerses, juzs: QuranJuzs, @@ -354,6 +356,14 @@ const createContentFacade = ( raw: Record, ) => { return { + answers: { + byAyah: (key: VerseKey, query?: ApiParams) => + answers.findByAyah(key, query), + countWithinRange: (from: VerseKey, to: VerseKey, query?: ApiParams) => + answers.countWithinRange(from, to, query), + get: (questionId: string | number) => + answers.findByQuestionId(questionId), + }, audio: { chapterRecitation: { get: (reciterId: string, chapterId: ChapterId, query?: ApiParams) => @@ -447,6 +457,7 @@ export const createRuntimeClient = ( ) => { const fetcher = new QuranFetcher(mode, config); fetcher.getFetch(); + const answers = new QuranAnswers(fetcher); const chapters = new QuranChapters(fetcher); const verses = new QuranVerses(fetcher); const juzs = new QuranJuzs(fetcher); @@ -474,6 +485,7 @@ export const createRuntimeClient = ( }; const contentV4 = createContentFacade( + answers, chapters, verses, juzs, @@ -503,6 +515,7 @@ export const createRuntimeClient = ( }); return { + answers, audio, auth: { ...authV1, diff --git a/packages/api/src/runtime/create-public-client.ts b/packages/api/src/runtime/create-public-client.ts index 3bebedd..ca15625 100644 --- a/packages/api/src/runtime/create-public-client.ts +++ b/packages/api/src/runtime/create-public-client.ts @@ -348,6 +348,7 @@ export const createPublicRuntimeClient = (config: PublicClientConfig) => { }; return { + answers: serverOnlyGuard, audio: serverOnlyGuard, auth: { ...authV1, diff --git a/packages/api/src/sdk/answers.ts b/packages/api/src/sdk/answers.ts new file mode 100644 index 0000000..1bc195c --- /dev/null +++ b/packages/api/src/sdk/answers.ts @@ -0,0 +1,108 @@ +import type { + AnswerCountWithinRangeResponse, + AnswerQuestion, + AnswersByAyahResponse, + BaseApiParams, + QuranFetchClient, + VerseKey, +} from "@/types"; +import { isValidVerseKey } from "@/utils"; + +type GetAnswersByAyahOptions = BaseApiParams & { + page?: number; + pageSize?: number; +}; + +type CountAnswersWithinRangeOptions = BaseApiParams; + +const normalizeAnswerCountWithinRangeResponse = ( + response: AnswerCountWithinRangeResponse, +): AnswerCountWithinRangeResponse => { + return Object.fromEntries( + Object.entries(response).map(([verseKey, count]) => { + if (!count.types) return [verseKey, count]; + + return [ + verseKey, + { + ...count, + types: Object.fromEntries( + Object.entries(count.types).map(([type, value]) => [ + type.toUpperCase(), + value, + ]), + ), + }, + ]; + }), + ); +}; + +/** + * Quran answers API methods. + */ +export class QuranAnswers { + constructor(private fetcher: QuranFetchClient) {} + + /** + * Get published answer questions linked to a specific ayah. + * @param {VerseKey} key verse key in format "chapter:verse" (e.g., "2:255") + * @param {GetAnswersByAyahOptions} options + * @example + * client.answers.findByAyah("2:255", { page: 1, pageSize: 2 }) + */ + async findByAyah( + key: VerseKey, + options?: GetAnswersByAyahOptions, + ): Promise { + if (!isValidVerseKey(key)) throw new Error("Invalid verse key"); + + return this.fetcher.fetch( + `/content/api/v4/answers/by_ayah/${key}`, + options, + ); + } + + /** + * Get a published answer question by id. + * @param {string | number} questionId question id + * @example + * client.answers.findByQuestionId("988") + */ + async findByQuestionId( + questionId: string | number, + ): Promise { + return this.fetcher.fetch( + `/content/api/v4/answers/${questionId}`, + ); + } + + /** + * Get a verse-key to answer-count map within an inclusive ayah range. + * @param {VerseKey} from start verse key in format "chapter:verse" + * @param {VerseKey} to end verse key in format "chapter:verse" + * @param {CountAnswersWithinRangeOptions} options + * @example + * client.answers.countWithinRange("2:255", "2:256") + */ + async countWithinRange( + from: VerseKey, + to: VerseKey, + options?: CountAnswersWithinRangeOptions, + ): Promise { + if (!isValidVerseKey(from) || !isValidVerseKey(to)) { + throw new Error("Invalid verse key"); + } + + const response = await this.fetcher.fetch( + "/content/api/v4/answers/count_within_range", + { + ...options, + from, + to, + }, + ); + + return normalizeAnswerCountWithinRangeResponse(response); + } +} diff --git a/packages/api/src/sdk/client.ts b/packages/api/src/sdk/client.ts index 5524f66..53c0b0c 100644 --- a/packages/api/src/sdk/client.ts +++ b/packages/api/src/sdk/client.ts @@ -12,6 +12,7 @@ import { paramsToString, removeBeginningSlash } from "@/lib/url"; import { Language } from "@/types"; import humps from "humps"; +import { QuranAnswers } from "./answers"; import { QuranAudio } from "./audio"; import { QuranChapters } from "./chapters"; import { QuranHadithReferences } from "./hadith-references"; @@ -130,6 +131,7 @@ export class QuranClient { private fetcher: LegacyQuranFetcher; public readonly chapters: QuranChapters; + public readonly answers: QuranAnswers; public readonly verses: QuranVerses; public readonly juzs: QuranJuzs; public readonly audio: QuranAudio; @@ -151,6 +153,7 @@ export class QuranClient { this.fetcher = new LegacyQuranFetcher(this.config); this.fetcher.getFetch(); + this.answers = new QuranAnswers(this.fetcher); this.chapters = new QuranChapters(this.fetcher); this.verses = new QuranVerses(this.fetcher); this.juzs = new QuranJuzs(this.fetcher); diff --git a/packages/api/src/types/api/Answer.ts b/packages/api/src/types/api/Answer.ts new file mode 100644 index 0000000..26693be --- /dev/null +++ b/packages/api/src/types/api/Answer.ts @@ -0,0 +1,35 @@ +import type { VerseKey } from "../common/verse-key"; + +export interface Answer { + id: string | number; + body: string; + answeredBy?: string; + status: string; + language?: string; +} + +export interface AnswerQuestion { + id: string | number; + body: string; + type: string; + ranges: VerseKey[]; + surah: number; + theme?: string[]; + summary?: string; + references?: string[]; + language?: string; + status: string; + answers: Answer[]; +} + +export interface AnswersByAyahResponse { + questions: AnswerQuestion[]; + totalCount?: number; +} + +export interface AnswerCount { + types?: Record; + total: number; +} + +export type AnswerCountWithinRangeResponse = Record; diff --git a/packages/api/src/types/api/index.ts b/packages/api/src/types/api/index.ts index d9283a0..bbb2441 100644 --- a/packages/api/src/types/api/index.ts +++ b/packages/api/src/types/api/index.ts @@ -1,4 +1,5 @@ export * from './ApiResponses'; +export * from './Answer'; export * from './AudioData'; export * from './AudioResponse'; export * from './Chapter'; diff --git a/packages/api/test/answers.test.ts b/packages/api/test/answers.test.ts new file mode 100644 index 0000000..faa5277 --- /dev/null +++ b/packages/api/test/answers.test.ts @@ -0,0 +1,101 @@ +import { http, HttpResponse } from "msw"; +import { describe, expect, it } from "vitest"; + +import { server } from "../mocks/server"; +import { testClient } from "./test-client"; + +describe("Answers API", () => { + describe("findByAyah()", () => { + it("should return answer questions for a valid ayah key", async () => { + const response = await testClient.answers.findByAyah("2:255"); + + expect(response.totalCount).toBe(1); + expect(response.questions[0]).toMatchObject({ + id: "question-1", + ranges: ["2:255"], + surah: 2, + }); + expect(response.questions[0]?.answers[0]?.answeredBy).toBe("Scholar"); + }); + + it("should preserve pageSize casing for the public answers contract", async () => { + let requestUrl = "http://missing.test"; + + server.use( + http.get( + "https://apis.quran.foundation/content/api/v4/answers/by_ayah/:ayah_key", + ({ request }) => { + requestUrl = request.url; + + return HttpResponse.json({ + questions: [], + totalCount: 0, + }); + }, + ), + ); + + await testClient.answers.findByAyah("2:255", { + language: "en", + page: 1, + pageSize: 2, + }); + + const params = new URL(requestUrl).searchParams; + expect(params.get("page")).toBe("1"); + expect(params.get("pageSize")).toBe("2"); + expect(params.get("page_size")).toBeNull(); + }); + + it("should throw for invalid ayah keys", async () => { + await expect( + // @ts-expect-error - invalid verse key + testClient.answers.findByAyah("0:1"), + ).rejects.toThrowError("Invalid verse key"); + }); + }); + + describe("findByQuestionId()", () => { + it("should return a published question by id", async () => { + const response = await testClient.answers.findByQuestionId("question-1"); + + expect(response.id).toBe("question-1"); + expect(response.answers[0]?.body).toContain("Ayat al-Kursi"); + }); + }); + + describe("countWithinRange()", () => { + it("should return answer counts for a verse range", async () => { + await expect( + testClient.answers.countWithinRange("2:255", "2:256"), + ).resolves.toEqual({ + "2:255": { + total: 2, + types: { + CLARIFICATION: 1, + TAFSIR: 1, + }, + }, + "2:256": { + total: 1, + types: { + TAFSIR: 1, + }, + }, + }); + }); + + it("should throw for invalid verse ranges", async () => { + await expect( + // @ts-expect-error - invalid verse key + testClient.answers.countWithinRange("2:255", "0:1"), + ).rejects.toThrowError("Invalid verse key"); + }); + + it("should expose the same methods through content.v4", async () => { + const response = await testClient.content.v4.answers.byAyah("2:255"); + + expect(response.questions[0]?.answers[0]?.id).toBe("answer-1"); + }); + }); +}); From be3c65eea21b35107602171148cfcb98e91c6949 Mon Sep 17 00:00:00 2001 From: Basit Minhas Date: Mon, 4 May 2026 15:16:52 +0500 Subject: [PATCH 2/2] Cover answers SDK review cases --- packages/api/test/answers.test.ts | 11 ++++- packages/api/test/legacy-client.test.ts | 60 +++++++++++++++++++++++++ packages/api/test/public-client.test.ts | 4 ++ 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/packages/api/test/answers.test.ts b/packages/api/test/answers.test.ts index faa5277..0417e6a 100644 --- a/packages/api/test/answers.test.ts +++ b/packages/api/test/answers.test.ts @@ -93,9 +93,16 @@ describe("Answers API", () => { }); it("should expose the same methods through content.v4", async () => { - const response = await testClient.content.v4.answers.byAyah("2:255"); + const byAyah = await testClient.content.v4.answers.byAyah("2:255"); + const answer = await testClient.content.v4.answers.get("question-1"); + const count = await testClient.content.v4.answers.countWithinRange( + "2:255", + "2:256", + ); - expect(response.questions[0]?.answers[0]?.id).toBe("answer-1"); + expect(byAyah.questions[0]?.answers[0]?.id).toBe("answer-1"); + expect(answer.answers[0]?.body).toContain("Ayat al-Kursi"); + expect(count["2:255"]?.types?.TAFSIR).toBe(1); }); }); }); diff --git a/packages/api/test/legacy-client.test.ts b/packages/api/test/legacy-client.test.ts index 48fe7e1..1d0cfc7 100644 --- a/packages/api/test/legacy-client.test.ts +++ b/packages/api/test/legacy-client.test.ts @@ -106,4 +106,64 @@ describe("QuranClient legacy compatibility", () => { perPage: 5, }); }); + + it("exposes answers through the legacy root client", async () => { + let answersUrl = "http://missing.test"; + + server.use( + http.post("http://localhost:5444/oauth2/token", () => + HttpResponse.json({ + access_token: "legacy-token", + expires_in: 3600, + scope: "content search", + token_type: "Bearer", + }), + ), + http.get( + "http://localhost:3020/content/api/v4/answers/by_ayah/:ayah_key", + ({ request }) => { + answersUrl = request.url; + expect(request.headers.get("x-auth-token")).toBe("legacy-token"); + expect(request.headers.get("x-client-id")).toBe("client-id"); + + return HttpResponse.json({ + questions: [ + { + id: "question-1", + answers: [], + body: "What is the context of this ayah?", + ranges: ["2:255"], + status: "Published", + surah: 2, + type: "TAFSIR", + }, + ], + totalCount: 1, + }); + }, + ), + ); + + const client = new QuranClient({ + authBaseUrl: "http://localhost:5444", + clientId: "client-id", + clientSecret: "client-secret", + contentBaseUrl: "http://localhost:3020", + }); + + await expect( + client.answers.findByAyah("2:255", { + language: "en", + pageSize: 2, + }), + ).resolves.toMatchObject({ + questions: [{ id: "question-1" }], + totalCount: 1, + }); + + const params = new URL(answersUrl).searchParams; + expect(params.get("language")).toBe("en"); + expect(params.get("pageSize")).toBe("2"); + expect(params.get("page_size")).toBeNull(); + }); }); diff --git a/packages/api/test/public-client.test.ts b/packages/api/test/public-client.test.ts index f36fb42..bc984af 100644 --- a/packages/api/test/public-client.test.ts +++ b/packages/api/test/public-client.test.ts @@ -48,8 +48,12 @@ describe("createPublicClient", () => { const content = client.content as { v4: { chapters: { list: () => Promise } }; }; + const answers = client.answers as { + findByAyah: (key: string) => Promise; + }; await expect(content.v4.chapters.list()).rejects.toThrowError(/server/i); + await expect(answers.findByAyah("2:255")).rejects.toThrowError(/server/i); const response = (await client.auth.v1.collections.list()) as CollectionListResponse;