diff --git a/package-lock.json b/package-lock.json index 5bd10ddf15..bc658d20bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "electron-updater": "^6.1.1", "electron-window-state": "^5.0.3", "fast-deep-equal": "^3.1.3", + "graphql": "^16.8.1", "http-status-codes": "^2.2.0", "jszip": "^3.10.1", "memoize-one": "^6.0.0", @@ -15207,6 +15208,14 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "node_modules/graphql": { + "version": "16.8.1", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.8.1.tgz", + "integrity": "sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/gunzip-maybe": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/gunzip-maybe/-/gunzip-maybe-1.4.2.tgz", @@ -36455,6 +36464,11 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "graphql": { + "version": "16.8.1", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.8.1.tgz", + "integrity": "sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==" + }, "gunzip-maybe": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/gunzip-maybe/-/gunzip-maybe-1.4.2.tgz", diff --git a/package.json b/package.json index 5b6354dff4..ed7735076d 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "electron-updater": "^6.1.1", "electron-window-state": "^5.0.3", "fast-deep-equal": "^3.1.3", + "graphql": "^16.8.1", "http-status-codes": "^2.2.0", "jszip": "^3.10.1", "memoize-one": "^6.0.0", diff --git a/src/shared/services/graphql.service.ts b/src/shared/services/graphql.service.ts new file mode 100644 index 0000000000..c4732f7f03 --- /dev/null +++ b/src/shared/services/graphql.service.ts @@ -0,0 +1,119 @@ +import { graphql, buildSchema } from 'graphql'; +import { VerseRef, ScrVers } from '@sillsdev/scripture'; +import projectLookupService from '@shared/services/project-lookup.service'; +import { ProjectMetadata } from '@shared/models/project-metadata.model'; +import { ProjectDataProvider } from '@shared/models/project-data-provider-engine.model'; +import { getProjectDataProvider } from '@shared/services/project-data-provider.service'; + +// TODO: figure out what to do with the schema. It's a baked in string just to get things rolling, not because it's optimal. +const usfmSchema = buildSchema(` + input VerseRef { + book: String! + chapter: String! + verse: String! + versification: String + } + + type Project { + id: String! + name: String! + storageType: String! + projectType: String! + } + + type Query { + projects: [Project] + getBook(projectId: String, verseRef: VerseRef): String + getChapter(projectId: String, verseRef: VerseRef): String + getVerse(projectId: String, verseRef: VerseRef): String + } +`); + +// There are probably frameworks that can be used to automatically convert between GraphQL and TS/JS types +// We should figure out how we want to use GraphQL in more detail before deciding which ones (if any) to use +function extractVerseRef(verseRef: object): VerseRef { + let versification: ScrVers | undefined; + if ('versification' in verseRef) versification = new ScrVers(verseRef.versification as string); + if ('book' in verseRef) { + const book = verseRef.book as string; + const chapter = 'chapter' in verseRef ? (verseRef.chapter as string) : '1'; + const verse = 'verse' in verseRef ? (verseRef.verse as string) : '1'; + return new VerseRef(book, chapter, verse, versification); + } + throw new Error(`Invalid verseRef: ${verseRef}`); +} + +// Caching some objects so we don't have to keep making network calls for every GraphQL query +const projectMap = new Map(); +const paratextProjectMap = new Map(); + +/** Transform the GraphQL inputs into objects we can work with */ +async function preparePdpCall( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + inputs: any, +): Promise<{ pdp: ProjectDataProvider['ParatextStandard']; verseRef: VerseRef }> { + const { projectId, verseRef }: { projectId: string; verseRef: Object } = inputs; + const parsedVerseRef = extractVerseRef(verseRef); + const existingPdp = paratextProjectMap.get(projectId); + if (existingPdp) return { pdp: existingPdp, verseRef: parsedVerseRef }; + const pdp = await getProjectDataProvider<'ParatextStandard'>(projectId); + paratextProjectMap.set(projectId, pdp); + return { pdp, verseRef: parsedVerseRef }; +} + +// This object hosts all of the resolvers. Each resolver is effectively a function call +const root = { + projects: async (): Promise => { + if (projectMap.size === 0) { + const allProjects = await projectLookupService.getMetadataForAllProjects(); + allProjects.forEach((proj) => projectMap.set(proj.id, proj)); + } + return [...projectMap.values()]; + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getBook: async (inputs: any): Promise => { + const { pdp, verseRef } = await preparePdpCall(inputs); + return pdp.getBook(verseRef); + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getChapter: async (inputs: any): Promise => { + const { pdp, verseRef } = await preparePdpCall(inputs); + return pdp.getChapter(verseRef); + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getVerse: async (inputs: any): Promise => { + const { pdp, verseRef } = await preparePdpCall(inputs); + return pdp.getVerse(verseRef); + }, +}; + +// It is unclear how to tell what sort of return type we'll have, so just give callers an easy +// type assert on our end. +/** + * Run a query + * @param query Text of a GraphQL query + * @returns Promise to whatever GraphQL resolves for the query + * @example + * runQuery('{ projects { id name } }'); + * runQuery('{ getBook(projectId: "b4c501ad2538989d6fb723518e92408406e232d3", verseRef: {book: "JUD", chapter: "1", verse: "1"}) }'); + */ +async function runQuery(query: string): Promise { + const results = await graphql({ + schema: usfmSchema, + rootValue: root, + source: query, + }); + + if (results.errors) throw new Error(JSON.stringify(results.errors)); + // If there is only 1 result, just give it to the caller instead of making the use a map to get it + if (results.data && Object.keys(results.data).length === 1) + return Object.values(results.data).at(0) as ReturnType; + return results.data as ReturnType; +} + +/** This is just a prototype service for running GraphQL queries. It's not ready for production as-is. */ +const graphqlService = { + runQuery, +}; + +export default graphqlService;