diff --git a/.changeset/typed-quranreflect-posts.md b/.changeset/typed-quranreflect-posts.md new file mode 100644 index 0000000..f777a40 --- /dev/null +++ b/.changeset/typed-quranreflect-posts.md @@ -0,0 +1,5 @@ +--- +"@quranjs/api": minor +--- + +Add typed QuranReflect post helpers for create, update, and get operations. diff --git a/packages/api/src/generated/specs/operation-catalog.json b/packages/api/src/generated/specs/operation-catalog.json index 0aa3f0d..72be8af 100644 --- a/packages/api/src/generated/specs/operation-catalog.json +++ b/packages/api/src/generated/specs/operation-catalog.json @@ -1001,6 +1001,11 @@ "method": "post", "path": "/v1/comments" }, + "commentsControllerEdit": { + "auth": "user", + "method": "patch", + "path": "/v1/comments/{id}" + }, "commentsControllerDeleteComment": { "auth": "user", "method": "get", diff --git a/packages/api/src/generated/specs/public-operation-catalog.json b/packages/api/src/generated/specs/public-operation-catalog.json index 7644287..806c9de 100644 --- a/packages/api/src/generated/specs/public-operation-catalog.json +++ b/packages/api/src/generated/specs/public-operation-catalog.json @@ -518,6 +518,11 @@ "method": "post", "path": "/v1/comments" }, + "commentsControllerEdit": { + "auth": "user", + "method": "patch", + "path": "/v1/comments/{id}" + }, "commentsControllerDeleteComment": { "auth": "user", "method": "get", diff --git a/packages/api/src/runtime/create-client.ts b/packages/api/src/runtime/create-client.ts index 81fe403..cc4cd3b 100644 --- a/packages/api/src/runtime/create-client.ts +++ b/packages/api/src/runtime/create-client.ts @@ -20,6 +20,8 @@ import { operationCatalog } from "@/generated/contracts"; import { toUserSession } from "@/lib/http-utils"; import { createGeneratedGroups, createRawClient } from "@/lib/runtime-utils"; import { replacePathParams } from "@/lib/url"; +import { createQuranReflectPostsFacade } from "@/runtime/quran-reflect-posts"; +import type { QuranReflectPostsFacade } from "@/runtime/quran-reflect-posts"; import { QuranAnswers } from "@/sdk/answers"; import { QuranAudio } from "@/sdk/audio"; import { QuranChapters } from "@/sdk/chapters"; @@ -36,7 +38,7 @@ type RawOperation = (request?: OperationRequest) => Promise; type GeneratedGroup = Record; type QuranReflectFacade = { comments: GeneratedGroup; - posts: GeneratedGroup; + posts: QuranReflectPostsFacade; raw: Record; rooms: GeneratedGroup; tags: GeneratedGroup; @@ -203,7 +205,10 @@ const createQuranReflectFacade = ( return { comments: generatedGroups.comments ?? {}, - posts: generatedGroups.posts ?? {}, + posts: createQuranReflectPostsFacade( + generatedGroups.posts ?? {}, + createUserServiceRequest(fetcher, "quranReflect"), + ), raw, rooms: generatedGroups.rooms ?? {}, tags: generatedGroups.tags ?? {}, diff --git a/packages/api/src/runtime/create-public-client.ts b/packages/api/src/runtime/create-public-client.ts index ca15625..8380abb 100644 --- a/packages/api/src/runtime/create-public-client.ts +++ b/packages/api/src/runtime/create-public-client.ts @@ -10,13 +10,15 @@ import { publicOperationCatalog } from "@/generated/public-contracts"; import { toUserSession } from "@/lib/http-utils"; import { createGeneratedGroups, createRawClient } from "@/lib/runtime-utils"; import { replacePathParams } from "@/lib/url"; +import { createQuranReflectPostsFacade } from "@/runtime/quran-reflect-posts"; +import type { QuranReflectPostsFacade } from "@/runtime/quran-reflect-posts"; import { PublicQuranFetcher } from "@/sdk/public-fetcher"; type RawOperation = (request?: OperationRequest) => Promise; type GeneratedGroup = Record; type QuranReflectFacade = { comments: GeneratedGroup; - posts: GeneratedGroup; + posts: QuranReflectPostsFacade; raw: Record; rooms: GeneratedGroup; tags: GeneratedGroup; @@ -188,7 +190,10 @@ const createQuranReflectFacade = ( return { comments: generatedGroups.comments ?? {}, - posts: generatedGroups.posts ?? {}, + posts: createQuranReflectPostsFacade( + generatedGroups.posts ?? {}, + createUserServiceRequest(fetcher, "quranReflect"), + ), raw, rooms: generatedGroups.rooms ?? {}, tags: generatedGroups.tags ?? {}, diff --git a/packages/api/src/runtime/quran-reflect-posts.ts b/packages/api/src/runtime/quran-reflect-posts.ts new file mode 100644 index 0000000..f35ec08 --- /dev/null +++ b/packages/api/src/runtime/quran-reflect-posts.ts @@ -0,0 +1,156 @@ +import type { + ApiParams, + CreateQuranReflectPostPayload, + OperationRequest, + QuranReflectPost, + QuranReflectPostMutationResponse, + UpdateQuranReflectPostPayload, +} from "@/types"; + +type RawOperation = (request?: OperationRequest) => Promise; +type GeneratedGroup = Record; + +export type QuranReflectPostCreateOperationRequest = Omit< + OperationRequest, + "body" +> & { + body: { + post: CreateQuranReflectPostPayload; + }; +}; + +export type QuranReflectPostUpdateOperationRequest = Omit< + OperationRequest, + "body" | "path" +> & { + body: UpdateQuranReflectPostPayload; + path: { + id: string | number; + }; +}; + +export type QuranReflectPostGetOperationRequest = Omit< + OperationRequest, + "path" +> & { + path: { + id: string | number; + }; +}; + +type CreateQuranReflectPost = { + ( + payload: CreateQuranReflectPostPayload, + ): Promise; + ( + request: QuranReflectPostCreateOperationRequest, + ): Promise; + (request?: OperationRequest): Promise; +}; + +type UpdateQuranReflectPost = { + ( + id: string | number, + payload: UpdateQuranReflectPostPayload, + ): Promise; + ( + request: QuranReflectPostUpdateOperationRequest, + ): Promise; + (request?: OperationRequest): Promise; +}; + +type GetQuranReflectPost = { + (id: string | number, query?: ApiParams): Promise; + (request: QuranReflectPostGetOperationRequest): Promise; + (request?: OperationRequest): Promise; +}; + +export type QuranReflectPostsFacade = GeneratedGroup & { + create: CreateQuranReflectPost; + get: GetQuranReflectPost; + update: UpdateQuranReflectPost; +}; + +const isObject = (value: unknown): value is Record => + Boolean(value) && typeof value === "object" && !Array.isArray(value); + +const isCreatePayload = (value: unknown): value is CreateQuranReflectPostPayload => { + if (!isObject(value)) { + return false; + } + + return ( + typeof value["body"] === "string" && + typeof value["draft"] === "boolean" && + Array.isArray(value["references"]) && + Array.isArray(value["mentions"]) + ); +}; + +export const createQuranReflectPostsFacade = ( + generatedPosts: GeneratedGroup, + request: ( + method: "GET" | "PATCH" | "POST", + path: string, + operationRequest?: OperationRequest, + ) => Promise, +): QuranReflectPostsFacade => { + const create: CreateQuranReflectPost = (( + payloadOrRequest: + | CreateQuranReflectPostPayload + | QuranReflectPostCreateOperationRequest, + ) => { + if (isCreatePayload(payloadOrRequest)) { + return request("POST", "/v1/posts", { + body: { + post: payloadOrRequest, + }, + }) as Promise; + } + + return request("POST", "/v1/posts", payloadOrRequest) as Promise< + QuranReflectPostMutationResponse + >; + }) as CreateQuranReflectPost; + + const update: UpdateQuranReflectPost = (( + idOrRequest: string | number | QuranReflectPostUpdateOperationRequest, + payload?: UpdateQuranReflectPostPayload, + ) => { + if (payload !== undefined) { + return request("PATCH", "/v1/posts/{id}", { + body: payload, + path: { id: idOrRequest as string | number }, + }) as Promise; + } + + return request( + "PATCH", + "/v1/posts/{id}", + idOrRequest as QuranReflectPostUpdateOperationRequest, + ) as Promise; + }) as UpdateQuranReflectPost; + + const get: GetQuranReflectPost = (( + idOrRequest: string | number | QuranReflectPostGetOperationRequest, + query?: ApiParams, + ) => { + if (typeof idOrRequest === "string" || typeof idOrRequest === "number") { + return request("GET", "/v1/posts/{id}", { + path: { id: idOrRequest }, + query, + }) as Promise; + } + + return request("GET", "/v1/posts/{id}", idOrRequest) as Promise< + QuranReflectPost + >; + }) as GetQuranReflectPost; + + return { + ...generatedPosts, + create, + get, + update, + }; +}; diff --git a/packages/api/src/types/index.ts b/packages/api/src/types/index.ts index 05784e7..a667ebf 100644 --- a/packages/api/src/types/index.ts +++ b/packages/api/src/types/index.ts @@ -125,3 +125,4 @@ export * from "./common/rub-number"; export * from "./api"; export * from "./BaseApiParams"; export * from "./quran-client"; +export * from "./quran-reflect"; diff --git a/packages/api/src/types/quran-client.ts b/packages/api/src/types/quran-client.ts index b0dfcc7..4a82031 100644 --- a/packages/api/src/types/quran-client.ts +++ b/packages/api/src/types/quran-client.ts @@ -1,4 +1,4 @@ -import { AuthService } from "@/generated/public-contracts"; +import type { AuthService } from "@/generated/public-contracts"; import type { ApiParams, BaseApiParams } from "./BaseApiParams"; diff --git a/packages/api/src/types/quran-reflect.ts b/packages/api/src/types/quran-reflect.ts new file mode 100644 index 0000000..f646c83 --- /dev/null +++ b/packages/api/src/types/quran-reflect.ts @@ -0,0 +1,69 @@ +export interface QuranReflectPostReference { + chapterId: number; + from: number; + to: number; + id?: string; +} + +export interface QuranReflectPostMention { + marker: string; + userId: string; + displayName: string; +} + +export type QuranReflectRoomPostStatus = 0 | 1 | 2; + +export interface CreateQuranReflectPostPayload { + body: string; + draft: boolean; + references: QuranReflectPostReference[]; + mentions: QuranReflectPostMention[]; + roomId?: number; + roomPostStatus?: QuranReflectRoomPostStatus; + postAsAuthorId?: string; + publishedAt?: string | Date; +} + +export type UpdateQuranReflectPostPayload = + Partial; + +export interface QuranReflectPost { + id: number | string; + authorId?: string; + body?: string; + commentsCount?: number; + createdAt?: string; + discussionId?: number; + draft?: boolean; + estimatedReadingTime?: number; + featuredAt?: string; + global?: boolean; + hidden?: boolean; + languageId?: number; + languageName?: string; + likesCount?: number; + mentions?: QuranReflectPostMention[]; + moderationStatus?: number; + postTypeId?: number | null; + postTypeName?: string; + publishedAt?: string; + pushedUpAt?: string; + references?: QuranReflectPostReference[]; + removed?: boolean; + reported?: boolean; + reviewedAt?: string; + roomId?: number | null; + roomPostStatus?: number; + toxicityScore?: number; + updatedAt?: string; + verified?: boolean; + views?: number; + viewsCount?: number; +} + +export interface QuranReflectPostMutationResponse { + success: boolean; + data?: QuranReflectPost; + post?: QuranReflectPost; + error?: unknown; +} diff --git a/packages/api/test/operation-catalog-generator.test.js b/packages/api/test/operation-catalog-generator.test.js index 0e81116..8332278 100644 --- a/packages/api/test/operation-catalog-generator.test.js +++ b/packages/api/test/operation-catalog-generator.test.js @@ -1,18 +1,34 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { describe, expect, it } from "vitest"; -import { - checkCatalogs, - generateCatalogs, -} from "../../../scripts/generate-operation-catalogs.mjs"; +const testDir = path.dirname(fileURLToPath(import.meta.url)); const writeJson = async (filePath, value) => { await fs.mkdir(path.dirname(filePath), { recursive: true }); await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`); }; +const loadCatalogModule = async () => { + const sourcePath = path.resolve( + testDir, + "../../../scripts/generate-operation-catalogs.mjs", + ); + const source = await fs.readFile(sourcePath, "utf8"); + const moduleDir = await fs.mkdtemp( + path.join(os.tmpdir(), "quranjs-catalog-module-"), + ); + const modulePath = path.join(moduleDir, "generate-operation-catalogs.mjs"); + + await fs.writeFile(modulePath, source.replace(/^#!.*(?:\r?\n|$)/, "")); + + return import(pathToFileURL(modulePath).href); +}; + +const catalogModule = loadCatalogModule(); + const operation = ( operationId, servers, @@ -28,6 +44,7 @@ const operation = ( describe("operation catalog generator", () => { it("builds compact server and public catalogs from OpenAPI specs", async () => { + const { generateCatalogs } = await catalogModule; const sourceDir = await fs.mkdtemp( path.join(os.tmpdir(), "quranjs-openapi-"), ); @@ -170,6 +187,7 @@ describe("operation catalog generator", () => { }); it("suffixes duplicate normalized operation names deterministically", async () => { + const { generateCatalogs } = await catalogModule; const sourceDir = await fs.mkdtemp( path.join(os.tmpdir(), "quranjs-openapi-"), ); @@ -211,6 +229,7 @@ describe("operation catalog generator", () => { }); it("reports missing generated catalog files as out of date", async () => { + const { checkCatalogs } = await catalogModule; const outputDir = await fs.mkdtemp( path.join(os.tmpdir(), "quranjs-catalog-output-"), ); diff --git a/packages/api/test/public-client.test.ts b/packages/api/test/public-client.test.ts index bc984af..0aa86c4 100644 --- a/packages/api/test/public-client.test.ts +++ b/packages/api/test/public-client.test.ts @@ -295,4 +295,52 @@ describe("createPublicClient", () => { ); expect(tokenRequests).toBe(0); }); + + it("wraps direct Quran Reflect post create payloads", async () => { + let postBody: unknown; + let postToken: string | null = null; + let postClientId: string | null = null; + + server.use( + http.post("http://localhost:3002/v1/posts", async ({ request }) => { + postBody = await request.json(); + postToken = request.headers.get("x-auth-token"); + postClientId = request.headers.get("x-client-id"); + + return HttpResponse.json({ + success: true, + data: { + id: 123, + body: "Reflection from public SDK", + draft: false, + references: [{ chapterId: 1, from: 1, to: 1 }], + }, + }); + }), + ); + + const client = createPublicClient({ + clientId: "client-id", + clientType: "confidential-proxy", + services: { + quranReflectBaseUrl: "http://localhost:3002", + }, + userSession: { + accessToken: "user-access-token", + }, + }); + const payload = { + body: "Reflection from public SDK", + draft: false, + mentions: [], + references: [{ chapterId: 1, from: 1, to: 1 }], + }; + + const response = await client.quranReflect.v1.posts.create(payload); + + expect(postBody).toEqual({ post: payload }); + expect(postToken).toBe("user-access-token"); + expect(postClientId).toBe("client-id"); + expect(response.data?.id).toBe(123); + }); }); diff --git a/packages/api/test/public-types.test.ts b/packages/api/test/public-types.test.ts index d93951c..9199cd9 100644 --- a/packages/api/test/public-types.test.ts +++ b/packages/api/test/public-types.test.ts @@ -89,5 +89,5 @@ describe("@quranjs/api/public type surface", () => { const program = ts.createProgram([sourceFile], options, compilerHost); expect(formatDiagnostics(ts.getPreEmitDiagnostics(program))).toBe(""); - }); + }, 15_000); }); diff --git a/packages/api/test/quran-reflect-post-types.test.ts b/packages/api/test/quran-reflect-post-types.test.ts new file mode 100644 index 0000000..6a8d4f6 --- /dev/null +++ b/packages/api/test/quran-reflect-post-types.test.ts @@ -0,0 +1,129 @@ +import path from "node:path"; +import ts from "typescript"; +import { describe, expect, it } from "vitest"; + +const formatDiagnostics = (diagnostics: readonly ts.Diagnostic[]): string => + diagnostics + .map((diagnostic) => { + const message = ts.flattenDiagnosticMessageText( + diagnostic.messageText, + "\n", + ); + if (!diagnostic.file || diagnostic.start === undefined) { + return `TS${diagnostic.code}: ${message}`; + } + + const position = diagnostic.file.getLineAndCharacterOfPosition( + diagnostic.start, + ); + const location = [ + path.basename(diagnostic.file.fileName), + position.line + 1, + position.character + 1, + ].join(":"); + return `${location} TS${diagnostic.code}: ${message}`; + }) + .join("\n"); + +describe("QuranReflect post type surface", () => { + it("types direct post payload helpers and the compatibility request shape", () => { + const sourceFile = path.join( + process.cwd(), + "test", + "__quran-reflect-post-types.ts", + ); + const source = ` + import type { + CreateQuranReflectPostPayload, + QuranReflectPostMutationResponse, + } from "@quranjs/api"; + import { createPublicClient } from "@quranjs/api/public"; + import { createServerClient } from "@quranjs/api/server"; + + const payload: CreateQuranReflectPostPayload = { + body: "Reflection text", + draft: false, + mentions: [], + references: [{ chapterId: 1, from: 1, to: 1 }], + }; + const serverClient = createServerClient({ + clientId: "client-id", + clientSecret: "client-secret", + }); + const publicClient = createPublicClient({ + clientId: "client-id", + clientType: "confidential-proxy", + }); + + void serverClient.quranReflect.v1.posts.create(payload).then( + (response: QuranReflectPostMutationResponse) => response.data?.id, + ); + void publicClient.quranReflect.v1.posts.create(payload); + void serverClient.quranReflect.v1.posts.create({ + body: { + post: payload, + }, + }); + void serverClient.quranReflect.v1.posts.update(123, { + body: "Updated reflection", + }); + void serverClient.quranReflect.v1.posts.get(123); + + const invalidPayload: CreateQuranReflectPostPayload = { + body: "Invalid reflection", + draft: false, + mentions: [], + references: [ + // @ts-expect-error - chapterId must be numeric. + { chapterId: "1", from: 1, to: 1 }, + ], + }; + + void invalidPayload; + `; + const options: ts.CompilerOptions = { + baseUrl: process.cwd(), + esModuleInterop: true, + lib: ["lib.es2022.d.ts", "lib.dom.d.ts"], + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + noEmit: true, + paths: { + "@/*": ["src/*"], + "@quranjs/api": ["src/index.ts"], + "@quranjs/api/public": ["src/public.ts"], + "@quranjs/api/server": ["src/server.ts"], + }, + skipLibCheck: true, + strict: true, + target: ts.ScriptTarget.ES2022, + }; + + const baseHost = ts.createCompilerHost(options); + const normalize = (filePath: string) => + path.resolve(filePath).toLowerCase(); + const normalizedSourceFile = normalize(sourceFile); + const compilerHost: ts.CompilerHost = { + ...baseHost, + fileExists: (filePath) => + normalize(filePath) === normalizedSourceFile || + baseHost.fileExists(filePath), + getSourceFile: (filePath, languageVersion, onError, shouldCreateNewFile) => + normalize(filePath) === normalizedSourceFile + ? ts.createSourceFile(filePath, source, languageVersion, true) + : baseHost.getSourceFile( + filePath, + languageVersion, + onError, + shouldCreateNewFile, + ), + readFile: (filePath) => + normalize(filePath) === normalizedSourceFile + ? source + : baseHost.readFile(filePath), + }; + + const program = ts.createProgram([sourceFile], options, compilerHost); + expect(formatDiagnostics(ts.getPreEmitDiagnostics(program))).toBe(""); + }, 15_000); +}); diff --git a/packages/api/test/server-client.test.ts b/packages/api/test/server-client.test.ts index cfa9b4b..d498c5c 100644 --- a/packages/api/test/server-client.test.ts +++ b/packages/api/test/server-client.test.ts @@ -711,4 +711,154 @@ describe("createServerClient", () => { refreshToken: "refresh-token-1", }); }); + + it("wraps direct Quran Reflect post create payloads", async () => { + let postBody: unknown; + let postToken: string | null = null; + let postClientId: string | null = null; + + server.use( + http.post("http://localhost:3002/v1/posts", async ({ request }) => { + postBody = await request.json(); + postToken = request.headers.get("x-auth-token"); + postClientId = request.headers.get("x-client-id"); + + return HttpResponse.json({ + success: true, + data: { + id: 123, + body: "Reflection from SDK", + draft: false, + references: [{ chapterId: 1, from: 1, to: 1 }], + }, + }); + }), + ); + + const client = createServerClient({ + clientId: "client-id", + clientSecret: "client-secret", + services: { + quranReflectBaseUrl: "http://localhost:3002", + }, + userSession: { + accessToken: "user-token", + }, + }); + const payload = { + body: "Reflection from SDK", + draft: false, + mentions: [], + references: [{ chapterId: 1, from: 1, to: 1 }], + }; + + const response = await client.quranReflect.v1.posts.create(payload); + + expect(postBody).toEqual({ post: payload }); + expect(postToken).toBe("user-token"); + expect(postClientId).toBe("client-id"); + expect(response.data?.id).toBe(123); + }); + + it("keeps Quran Reflect generated create operation request compatibility", async () => { + const postBodies: unknown[] = []; + + server.use( + http.post("http://localhost:3002/v1/posts", async ({ request }) => { + postBodies.push(await request.json()); + + return HttpResponse.json({ + success: true, + data: { + id: 456, + }, + }); + }), + ); + + const client = createServerClient({ + clientId: "client-id", + clientSecret: "client-secret", + services: { + quranReflectBaseUrl: "http://localhost:3002", + }, + userSession: { + accessToken: "user-token", + }, + }); + const payload = { + body: "Reflection from generated request shape", + draft: false, + mentions: [], + references: [{ chapterId: 2, from: 255, to: 255 }], + }; + + await client.quranReflect.v1.posts.create({ + body: { + post: payload, + }, + }); + + await client.quranReflect.v1.posts.create({ + body: JSON.stringify({ post: payload }), + headers: { + "x-test": "raw-json-body", + }, + }); + + expect(postBodies).toEqual([{ post: payload }, { post: payload }]); + }); + + it("wraps typed Quran Reflect post update and get helpers", async () => { + let updateBody: unknown; + let updateUrl: string | null = null; + let getUrl: string | null = null; + + server.use( + http.patch("http://localhost:3002/v1/posts/123", async ({ request }) => { + updateUrl = request.url; + updateBody = await request.json(); + + return HttpResponse.json({ + success: true, + data: { + id: 123, + body: "Updated reflection", + }, + }); + }), + http.get("http://localhost:3002/v1/posts/123", ({ request }) => { + getUrl = request.url; + + return HttpResponse.json({ + id: 123, + body: "Updated reflection", + }); + }), + ); + + const client = createServerClient({ + clientId: "client-id", + clientSecret: "client-secret", + services: { + quranReflectBaseUrl: "http://localhost:3002", + }, + userSession: { + accessToken: "user-token", + }, + }); + + await client.quranReflect.v1.posts.update(123, { + body: "Updated reflection", + }); + await client.quranReflect.v1.posts.get(123, { include: "references" }); + + expect(updateUrl).toBe("http://localhost:3002/v1/posts/123"); + expect(updateBody).toEqual({ + body: "Updated reflection", + }); + expect(getUrl).toBe( + "http://localhost:3002/v1/posts/123?include=references", + ); + }); });