From e24e34eca50a078422bf466988cc62022df9a89f Mon Sep 17 00:00:00 2001 From: Marouane Date: Wed, 4 Nov 2026 21:49:59 +0000 Subject: [PATCH] fix(api): keep Quran Reflect query casing on content-catalog paths The content catalog includes the public Quran Reflect reads (`postsControllerFeed`, `postsControllerGetUserPost` and the post and comment reads). Their paths start with `/quran-reflect/v1`, so the request goes to Quran Reflect through the gateway, but `buildServiceUrl` chose the query key casing from the calling service. As a content call, every key was decamelized: `sortBy`, `postTypeIds`, `filter[verifiedOnly]` and `filter[references][0][chapterId]` went out as `sort_by`, `post_type_ids`, `filter[verified_only]` and `filter[references][0][chapter_id]`. Both the content and the Quran Reflect specs declare the camelCase names, and the same operations through `client.quranReflect.v1.raw` already keep them. Take the casing from the service that serves the path. Only cross-service paths change; content, search and analytics paths still send snake_case keys. Co-Authored-By: Claude Opus 5.5 --- .../quranreflect-catalog-query-casing.md | 5 + packages/api/src/sdk/fetcher.ts | 23 ++-- packages/api/test/raw-operation.test.ts | 111 ++++++++++++++++++ 3 files changed, 128 insertions(+), 11 deletions(-) create mode 100644 .changeset/quranreflect-catalog-query-casing.md diff --git a/.changeset/quranreflect-catalog-query-casing.md b/.changeset/quranreflect-catalog-query-casing.md new file mode 100644 index 0000000..916a996 --- /dev/null +++ b/.changeset/quranreflect-catalog-query-casing.md @@ -0,0 +1,5 @@ +--- +"@quranjs/api": patch +--- + +Keep the camelCase query keys of the Quran Reflect reads in the content catalog (`client.content.v4.raw.postsControllerFeed()` and the other `postsController*` operations). They are served by Quran Reflect under `/quran-reflect/v1`, but their query keys were converted to snake_case like a content call, so `sortBy`, `postTypeIds`, `filter[verifiedOnly]` and `filter[references][0][chapterId]` went out as `sort_by`, `post_type_ids`, `filter[verified_only]` and `filter[references][0][chapter_id]`, which the API does not read. They are now sent as written, the same as through `client.quranReflect.v1.raw`. Content API queries are still sent in snake_case. diff --git a/packages/api/src/sdk/fetcher.ts b/packages/api/src/sdk/fetcher.ts index e39a067..c37f214 100644 --- a/packages/api/src/sdk/fetcher.ts +++ b/packages/api/src/sdk/fetcher.ts @@ -168,9 +168,9 @@ export class QuranFetcher { path: string, query?: ApiParams, ): string { - const crossServiceGatewayPath = this.isCrossServiceGatewayPath( - service, - path, + const pathService = this.gatewayPathService(path); + const crossServiceGatewayPath = Boolean( + pathService && pathService !== service, ); const { baseUrl, usesGateway } = this.resolveServiceBaseUrl( service, @@ -182,8 +182,12 @@ export class QuranFetcher { usesGateway, crossServiceGatewayPath, ); + // A cross-service path is served by the service its prefix names, so that service's query + // conventions apply. The content catalog's Quran Reflect reads go to /quran-reflect/v1, + // which takes camelCase keys such as `sortBy` and `filter[references][0][chapterId]`. + const queryService = crossServiceGatewayPath ? pathService : service; const preserveQueryParamCase = - service === "auth" || service === "quranReflect"; + queryService === "auth" || queryService === "quranReflect"; return `${baseUrl}${servicePath}${paramsToString(query, { preserveCase: preserveQueryParamCase, @@ -1084,19 +1088,16 @@ export class QuranFetcher { return `${prefix}${servicePath}`; } - private isCrossServiceGatewayPath( - service: ApiService, - path: string, - ): boolean { + /** The service whose gateway path prefix the path starts with, if any. */ + private gatewayPathService(path: string): ApiService | undefined { const normalizedPath = ensureLeadingSlash(normalizePathTemplate(path)); - const pathService = GATEWAY_SERVICES.find((gatewayService) => + + return GATEWAY_SERVICES.find((gatewayService) => this.pathMatchesPrefix( normalizedPath, GATEWAY_PATH_PREFIX[gatewayService], ), ); - - return Boolean(pathService && pathService !== service); } private pathMatchesPrefix(path: string, prefix: string): boolean { diff --git a/packages/api/test/raw-operation.test.ts b/packages/api/test/raw-operation.test.ts index d1a6280..bc9b00a 100644 --- a/packages/api/test/raw-operation.test.ts +++ b/packages/api/test/raw-operation.test.ts @@ -360,6 +360,117 @@ describe("raw operation requests", () => { expect(requestedScopes).toEqual(["post.read", "comment.read"]); }); + it("keeps Quran Reflect query casing on content-catalog gateway paths", () => { + const fetcher = new QuranFetcher("server", { + clientId: "client-id", + clientSecret: "client-secret", + services: { + contentBaseUrl: "http://localhost:3020", + gatewayUrl: "http://localhost:3000", + }, + }); + + const feedParams = new URL( + fetcher.buildServiceUrl("content", "/quran-reflect/v1/posts/feed", { + "filter[references][0][chapterId]": 2, + "filter[references][0][from]": 255, + "filter[references][0][to]": 255, + "filter[verifiedOnly]": true, + }), + ).searchParams; + + expect(feedParams.get("filter[references][0][chapterId]")).toBe("2"); + expect(feedParams.get("filter[verifiedOnly]")).toBe("true"); + expect(feedParams.has("filter[references][0][chapter_id]")).toBe(false); + expect(feedParams.has("filter[verified_only]")).toBe(false); + expect( + fetcher.buildServiceUrl( + "content", + "/quran-reflect/v1/posts/user-posts/abc", + { + postTypeIds: [1, 2], + sortBy: "popular", + }, + ), + ).toBe( + "http://localhost:3000/quran-reflect/v1/posts/user-posts/abc?postTypeIds=1%2C2&sortBy=popular", + ); + + // Paths served by the content API itself keep their snake_case query keys. + expect( + fetcher.buildServiceUrl("content", "/chapters/1/info", { + includeResources: true, + resourceId: 131, + }), + ).toBe( + "http://localhost:3020/api/v4/chapters/1/info?include_resources=true&resource_id=131", + ); + expect( + fetcher.buildServiceUrl("content", "/content/api/v4/verses/by_key/1:1", { + perPage: 10, + }), + ).toBe("http://localhost:3020/api/v4/verses/by_key/1:1?per_page=10"); + }); + + it("sends the same Quran Reflect query through the content and Quran Reflect catalogs", async () => { + const receivedQueries: URLSearchParams[] = []; + + server.use( + http.post("http://localhost:5444/oauth2/token", async ({ request }) => { + const scope = + new URLSearchParams(await request.text()).get("scope") ?? ""; + + return HttpResponse.json({ + access_token: `${scope}-token`, + expires_in: 3600, + scope, + token_type: "Bearer", + }); + }), + http.get( + "http://localhost:3020/quran-reflect/v1/posts/feed", + ({ request }) => { + receivedQueries.push(new URL(request.url).searchParams); + + return HttpResponse.json({ data: [] }); + }, + ), + ); + + const client = createServerClient({ + clientId: "client-id", + clientSecret: "client-secret", + services: { + gatewayUrl: "http://localhost:3020", + tokenHost: "http://localhost:5444", + }, + userSession: { + accessToken: "user-token", + }, + }); + const query = { + "filter[references][0][chapterId]": 2, + "filter[references][0][from]": 255, + "filter[references][0][to]": 255, + "filter[verifiedOnly]": true, + sortBy: "popular", + }; + + // App token, through the content catalog. + await client.content.v4.raw.postsControllerFeed?.({ query }); + // User token, through the Quran Reflect catalog. + await client.quranReflect.v1.raw.postsControllerFeed?.({ query }); + + expect(receivedQueries).toHaveLength(2); + for (const params of receivedQueries) { + expect(params.get("filter[references][0][chapterId]")).toBe("2"); + expect(params.get("filter[references][0][from]")).toBe("255"); + expect(params.get("filter[verifiedOnly]")).toBe("true"); + expect(params.get("sortBy")).toBe("popular"); + expect(params.has("sort_by")).toBe(false); + } + }); + it("replaces public raw operation path params before fetching", async () => { let noteUrl: string | null = null;