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/quranreflect-catalog-query-casing.md
Original file line number Diff line number Diff line change
@@ -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.
23 changes: 12 additions & 11 deletions packages/api/src/sdk/fetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
111 changes: 111 additions & 0 deletions packages/api/test/raw-operation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down