From 26b76ba9cedeb08efc3bee3017a4fdf85916fe29 Mon Sep 17 00:00:00 2001 From: Edoardo Dusi Date: Fri, 15 Nov 2024 16:50:28 +0100 Subject: [PATCH] feat: new graphql method on the client, wrapping graphql calls for the gapi endpoint --- package.json | 3 +- pnpm-lock.yaml | 16 ++++ src/constants.ts | 2 + src/graphql-wrapper.test.ts | 78 ++++++++++++++++++ src/graphql-wrapper.ts | 41 ++++++++++ src/index.ts | 13 ++- src/storyblok.d.ts | 67 +++++++++++++++ tests/api/index.e2e.ts | 159 ++++++++++++++++++------------------ tests/setup.js | 2 +- vitest.config.e2e.ts | 4 +- vitest.config.ts | 4 +- 11 files changed, 302 insertions(+), 87 deletions(-) create mode 100644 src/graphql-wrapper.test.ts create mode 100644 src/graphql-wrapper.ts create mode 100644 src/storyblok.d.ts diff --git a/package.json b/package.json index a4b0d608..6aad9fa2 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,8 @@ "vite": "^5.4.11", "vite-plugin-banner": "^0.8.0", "vite-plugin-dts": "^4.3.0", - "vitest": "^2.1.4" + "vitest": "^2.1.4", + "vitest-fetch-mock": "^0.4.2" }, "release": { "branches": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2498fc1e..7cf60eef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: vitest: specifier: ^2.1.4 version: 2.1.4(@types/node@22.4.2)(@vitest/ui@2.1.4) + vitest-fetch-mock: + specifier: ^0.4.2 + version: 0.4.2(vitest@2.1.4) playground/nextjs: devDependencies: @@ -684,6 +687,7 @@ packages: '@humanwhocodes/config-array@0.11.14': resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} engines: {node: '>=10.10.0'} + deprecated: Use @eslint/config-array instead '@humanwhocodes/module-importer@1.0.1': resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} @@ -691,6 +695,7 @@ packages: '@humanwhocodes/object-schema@2.0.3': resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} + deprecated: Use @eslint/object-schema instead '@humanwhocodes/retry@0.3.1': resolution: {integrity: sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==} @@ -2054,6 +2059,7 @@ packages: eslint@8.55.0: resolution: {integrity: sha512-iyUUAM0PCKj5QpwGfmCAG9XXbZCWsqP/eWAWrG/W0umvjuLRBECwSFdt+rCntju0xEH7teIABPwXpahftIaTdA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true eslint@9.14.0: @@ -3726,6 +3732,12 @@ packages: vite: optional: true + vitest-fetch-mock@0.4.2: + resolution: {integrity: sha512-MuN/TCAvvUs9sLMdOPKqdXEUOD0E5cNW/LN7Tro3KkrLBsvUaH7iQWcznNUU4ml+GqX6ZbNguDmFQ2tliKqhCg==} + engines: {node: '>=18.0.0'} + peerDependencies: + vitest: '>=2.0.0' + vitest@2.1.4: resolution: {integrity: sha512-eDjxbVAJw1UJJCHr5xr/xM86Zx+YxIEXGAR+bmnEID7z9qWfoxpHw0zdobz+TQAFOLT+nEXz3+gx6nUJ7RgmlQ==} engines: {node: ^18.0.0 || >=20.0.0} @@ -7864,6 +7876,10 @@ snapshots: optionalDependencies: vite: 5.4.2(@types/node@22.4.2) + vitest-fetch-mock@0.4.2(vitest@2.1.4): + dependencies: + vitest: 2.1.4(@types/node@22.4.2)(@vitest/ui@2.1.4) + vitest@2.1.4(@types/node@22.4.2)(@vitest/ui@2.1.4): dependencies: '@vitest/expect': 2.1.4 diff --git a/src/constants.ts b/src/constants.ts index 744b1bd2..36a4422b 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -17,3 +17,5 @@ export const STORYBLOK_JS_CLIENT_AGENT = { defaultAgentVersion: 'SB-Agent-Version', packageVersion: '6.0.0', }; + +export const STORYBLOK_GRAPQL_API = 'https://gapi.storyblok.com/v1/api'; diff --git a/src/graphql-wrapper.test.ts b/src/graphql-wrapper.test.ts new file mode 100644 index 00000000..f76503a1 --- /dev/null +++ b/src/graphql-wrapper.test.ts @@ -0,0 +1,78 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import createFetchMock from 'vitest-fetch-mock'; + +describe('test graphql wrapper', () => { + const query = ` + query { + PageItem(id: "home") { + name + content { + _uid + component + } + } + } + `; + + const accessToken = 'test-access-token'; + const version = 'draft'; + const variables = { id: '123' }; + + beforeAll(() => { + const fetchMocker = createFetchMock(vi); + // sets globalThis.fetch and globalThis.fetchMock to our mocked version + fetchMocker.enableMocks(); + }); + + beforeEach(() => { + fetch.resetMocks(); + }); + + it('should return data when the request is successful', async () => { + fetch.mockResponseOnce(JSON.stringify({ data: { test: 'test' } })); + + const { graph } = await import('./graphql-wrapper'); + const response = await graph(query, accessToken, version, variables); + + expect(response).toEqual({ data: { test: 'test' } }); + }); + + it('should throw an error when the request fails', async () => { + fetch.mockRejectOnce(new Error('test error')); + + const { graph } = await import('./graphql-wrapper'); + + try { + await graph(query, accessToken, version, variables); + } + catch (error) { + expect(error.message).toBe('GraphQL request failed: test error'); + } + }); + + it('should throw an error when the response status is not ok', async () => { + fetch.mockResponseOnce(JSON.stringify({ data: { test: 'test' } }), { status: 401 }); + + const { graph } = await import('./graphql-wrapper'); + + try { + await graph(query, accessToken, version, variables); + } + catch (error) { + expect(error.message).toBe('GraphQL request failed with status 401'); + } + }); + + it('should throw an error when the response is not JSON', async () => { + fetch.mockResponseOnce('not json', { status: 200 }); + + const { graph } = await import('./graphql-wrapper'); + + try { + await graph(query, accessToken, version, variables); + } + catch (error) { + expect(error.message).toContain('Unexpected token'); + } + }); +}); diff --git a/src/graphql-wrapper.ts b/src/graphql-wrapper.ts new file mode 100644 index 00000000..ff49ff94 --- /dev/null +++ b/src/graphql-wrapper.ts @@ -0,0 +1,41 @@ +import { STORYBLOK_GRAPQL_API } from './constants'; + +/** + * Wrapper for Storyblok GraphQL API + * + * @param query string + * @param accessToken string + * @param version 'draft' | 'published' + * @param variables Record + * @returns Promise<{ data: object }> + * + * @throws Error + */ +export async function graph( + query: string, + accessToken: string, + version: 'draft' | 'published' = 'draft', + variables?: Record, +): Promise<{ data: object }> { + let response; + try { + response = await fetch(STORYBLOK_GRAPQL_API, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'token': accessToken, + 'version': version, + }, + body: JSON.stringify({ query, variables }), + }); + } + catch (error) { + throw new Error(`GraphQL request failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + + if (!response.ok) { + throw new Error(`GraphQL request failed with status ${response.status}`); + } + + return response.json(); +} diff --git a/src/index.ts b/src/index.ts index 47d92f78..4e38f65c 100755 --- a/src/index.ts +++ b/src/index.ts @@ -22,6 +22,8 @@ import type { ISbStoryParams, ThrottleFn, } from './interfaces'; +import type { IStoryblok } from './storyblok'; +import { graph } from './graphql-wrapper'; let memory: Partial = {}; @@ -64,7 +66,7 @@ const _VERSION = { type ObjectValues = T[keyof T]; type Version = ObjectValues; -class Storyblok { +class Storyblok implements IStoryblok { private client: SbFetch; private maxRetries: number; private retriesDelay: number; @@ -751,6 +753,15 @@ class Storyblok { this.clearCacheVersion(); return this; } + + // Wrap GraphQL queries + public async graphql( + query: string, + version: 'draft' | 'published' = 'draft', + variables?: Record, + ): Promise<{ data: object }> { + return graph(query, this.accessToken, version, variables); + } } export default Storyblok; diff --git a/src/storyblok.d.ts b/src/storyblok.d.ts new file mode 100644 index 00000000..31a17fa8 --- /dev/null +++ b/src/storyblok.d.ts @@ -0,0 +1,67 @@ +import type { + CachedVersions, + ComponentResolverFn, + ISbContentMangmntAPI, + ISbCustomFetch, + ISbResponseData, + ISbResult, + ISbStories, + ISbStoriesParams, + ISbStory, + ISbStoryParams, + LinksType, + RelationsType, + RichTextResolver, +} from './interfaces'; + +export interface IStoryblok { + relations: RelationsType; + links: LinksType; + richTextResolver: RichTextResolver; + resolveNestedRelations: boolean; + + // Sets the component resolver for rich text + setComponentResolver: (resolver: ComponentResolverFn) => void; + + // Fetches a single story by slug + get: (slug: string, params?: ISbStoriesParams, fetchOptions?: ISbCustomFetch) => Promise; + + // Fetches all stories matching the given parameters + getAll: (slug: string, params: ISbStoriesParams, entity?: string, fetchOptions?: ISbCustomFetch) => Promise; + + // Creates a new story + post: (slug: string, params: ISbStoriesParams | ISbContentMangmntAPI, fetchOptions?: ISbCustomFetch) => Promise; + + // Updates an existing story + put: (slug: string, params: ISbStoriesParams | ISbContentMangmntAPI, fetchOptions?: ISbCustomFetch) => Promise; + + // Deletes a story + delete: (slug: string, params: ISbStoriesParams | ISbContentMangmntAPI, fetchOptions?: ISbCustomFetch) => Promise; + + // Fetches multiple stories + getStories: (params: ISbStoriesParams, fetchOptions?: ISbCustomFetch) => Promise; + + // Fetches a single story by slug + getStory: (slug: string, params: ISbStoryParams, fetchOptions?: ISbCustomFetch) => Promise; + + // Wrapper for GraphQL queries + graphql: (query: string, version: 'draft' | 'published', variables?: Record) => Promise<{ data: object }>; + + // Ejects the interceptor from the fetch client + ejectInterceptor: () => void; + + // Flushes all caches + flushCache: () => Promise; + + // Returns all cached versions (cv) + cacheVersions: () => CachedVersions; + + // Returns the current cache version (cv) + cacheVersion: () => number; + + // Sets the cache version (cv) + setCacheVersion: (cv: number) => void; + + // Clears the cache version + clearCacheVersion: () => void; +} diff --git a/tests/api/index.e2e.ts b/tests/api/index.e2e.ts index 5c02ec6a..3f882712 100644 --- a/tests/api/index.e2e.ts +++ b/tests/api/index.e2e.ts @@ -1,16 +1,16 @@ -import StoryblokClient from 'storyblok-js-client' -import { describe, it, expect, beforeEach } from 'vitest' +import StoryblokClient from 'storyblok-js-client'; +import { beforeEach, describe, expect, it } from 'vitest'; describe('StoryblokClient', () => { - let client + let client; beforeEach(() => { // Setup default mocks client = new StoryblokClient({ accessToken: process.env.VITE_ACCESS_TOKEN, cache: { type: 'memory', clear: 'auto' }, - }) - }) + }); + }); // TODO: Uncomment when we have a valid token /* if (process.env.VITE_OAUTH_TOKEN) { describe('management API', () => { @@ -28,72 +28,71 @@ describe('StoryblokClient', () => { } */ describe('get function', () => { - it("get('cdn/spaces/me') should return the space information", async () => { - const { data } = await client.get('cdn/spaces/me') - expect(data.space.id).toBe(Number(process.env.VITE_SPACE_ID)) - }) - - it("get('cdn/stories') should return all stories", async () => { - const { data } = await client.get('cdn/stories') - expect(data.stories.length).toBeGreaterThan(0) - }) - - it("get('cdn/stories/testcontent-0' should return the specific story", async () => { - const { data } = await client.get('cdn/stories/testcontent-0') - expect(data.story.slug).toBe('testcontent-0') - }) - - it("get('cdn/stories' { starts_with: testcontent-0 } should return the specific story", async () => { + it('get(\'cdn/spaces/me\') should return the space information', async () => { + const { data } = await client.get('cdn/spaces/me'); + expect(data.space.id).toBe(Number(process.env.VITE_SPACE_ID)); + }); + + it('get(\'cdn/stories\') should return all stories', async () => { + const { data } = await client.get('cdn/stories'); + expect(data.stories.length).toBeGreaterThan(0); + }); + + it('get(\'cdn/stories/testcontent-0\' should return the specific story', async () => { + const { data } = await client.get('cdn/stories/testcontent-0'); + expect(data.story.slug).toBe('testcontent-0'); + }); + + it('get(\'cdn/stories\' { starts_with: testcontent-0 } should return the specific story', async () => { const { data } = await client.get('cdn/stories', { starts_with: 'testcontent-0', - }) - expect(data.stories.length).toBe(1) - }) + }); + expect(data.stories.length).toBe(1); + }); - it("get('cdn/stories/testcontent-draft', { version: 'draft' }) should return the specific story draft", async () => { + it('get(\'cdn/stories/testcontent-draft\', { version: \'draft\' }) should return the specific story draft', async () => { const { data } = await client.get('cdn/stories/testcontent-draft', { version: 'draft', - }) - expect(data.story.slug).toBe('testcontent-draft') - }) + }); + expect(data.story.slug).toBe('testcontent-draft'); + }); - it("get('cdn/stories/testcontent-0', { version: 'published' }) should return the specific story published", async () => { + it('get(\'cdn/stories/testcontent-0\', { version: \'published\' }) should return the specific story published', async () => { const { data } = await client.get('cdn/stories/testcontent-0', { version: 'published', - }) - expect(data.story.slug).toBe('testcontent-0') - }) + }); + expect(data.story.slug).toBe('testcontent-0'); + }); it('cdn/stories/testcontent-0 should resolve author relations', async () => { const { data } = await client.get('cdn/stories/testcontent-0', { resolve_relations: 'root.author', - }) - console.log(data) - expect(data.story.content.author[0].slug).toBe('edgar-allan-poe') - }) + }); + expect(data.story.content.author[0].slug).toBe('edgar-allan-poe'); + }); - it("get('cdn/stories', { by_slugs: 'folder/*' }) should return the specific story", async () => { + it('get(\'cdn/stories\', { by_slugs: \'folder/*\' }) should return the specific story', async () => { const { data } = await client.get('cdn/stories', { by_slugs: 'folder/*', - }) - expect(data.stories.length).toBeGreaterThan(0) - }) - }) + }); + expect(data.stories.length).toBeGreaterThan(0); + }); + }); describe('getAll function', () => { - it("getAll('cdn/stories') should return all stories", async () => { - const result = await client.getAll('cdn/stories') - expect(result.length).toBeGreaterThan(0) - }) + it('getAll(\'cdn/stories\') should return all stories', async () => { + const result = await client.getAll('cdn/stories'); + expect(result.length).toBeGreaterThan(0); + }); - it("getAll('cdn/stories') should return all stories with filtered results", async () => { + it('getAll(\'cdn/stories\') should return all stories with filtered results', async () => { const result = await client.getAll('cdn/stories', { starts_with: 'testcontent-0', - }) - expect(result.length).toBe(1) - }) + }); + expect(result.length).toBe(1); + }); - it("getAll('cdn/stories', filter_query: { __or: [{ category: { any_in_array: 'Category 1' } }, { category: { any_in_array: 'Category 2' } }]}) should return all stories with the specific filter applied", async () => { + it('getAll(\'cdn/stories\', filter_query: { __or: [{ category: { any_in_array: \'Category 1\' } }, { category: { any_in_array: \'Category 2\' } }]}) should return all stories with the specific filter applied', async () => { const result = await client.getAll('cdn/stories', { filter_query: { __or: [ @@ -101,47 +100,47 @@ describe('StoryblokClient', () => { { category: { any_in_array: 'Category 2' } }, ], }, - }) - expect(result.length).toBeGreaterThan(0) - }) + }); + expect(result.length).toBeGreaterThan(0); + }); - it("getAll('cdn/stories', {by_slugs: 'folder/*'}) should return all stories with the specific filter applied", async () => { + it('getAll(\'cdn/stories\', {by_slugs: \'folder/*\'}) should return all stories with the specific filter applied', async () => { const result = await client.getAll('cdn/stories', { by_slugs: 'folder/*', - }) - expect(result.length).toBeGreaterThan(0) - }) + }); + expect(result.length).toBeGreaterThan(0); + }); - it("getAll('cdn/links') should return all links", async () => { - const result = await client.getAll('cdn/links') - expect(result.length).toBeGreaterThan(0) - }) - }) + it('getAll(\'cdn/links\') should return all links', async () => { + const result = await client.getAll('cdn/links'); + expect(result.length).toBeGreaterThan(0); + }); + }); describe('caching', () => { - it("get('cdn/spaces/me') should not be cached", async () => { - const provider = client.cacheProvider() - await provider.flush() - await client.get('cdn/spaces/me') - expect(Object.values(provider.getAll()).length).toBe(0) - }) + it('get(\'cdn/spaces/me\') should not be cached', async () => { + const provider = client.cacheProvider(); + await provider.flush(); + await client.get('cdn/spaces/me'); + expect(Object.values(provider.getAll()).length).toBe(0); + }); - it("get('cdn/stories') should be cached when is a published version", async () => { - const cacheVersion = client.cacheVersion() + it('get(\'cdn/stories\') should be cached when is a published version', async () => { + const cacheVersion = client.cacheVersion(); - await client.get('cdn/stories') + await client.get('cdn/stories'); - expect(cacheVersion).not.toBe(undefined) + expect(cacheVersion).not.toBe(undefined); - const newCacheVersion = client.cacheVersion() + const newCacheVersion = client.cacheVersion(); - await client.get('cdn/stories') + await client.get('cdn/stories'); - expect(newCacheVersion).toBe(client.cacheVersion()) + expect(newCacheVersion).toBe(client.cacheVersion()); - await client.get('cdn/stories') + await client.get('cdn/stories'); - expect(newCacheVersion).toBe(client.cacheVersion()) - }) - }) -}) + expect(newCacheVersion).toBe(client.cacheVersion()); + }); + }); +}); diff --git a/tests/setup.js b/tests/setup.js index 855c2532..9f4b4e80 100644 --- a/tests/setup.js +++ b/tests/setup.js @@ -1 +1 @@ -import 'isomorphic-fetch' +import 'isomorphic-fetch'; diff --git a/vitest.config.e2e.ts b/vitest.config.e2e.ts index dcdc8643..b41f22bf 100644 --- a/vitest.config.e2e.ts +++ b/vitest.config.e2e.ts @@ -1,8 +1,8 @@ -import { defineConfig } from 'vite' +import { defineConfig } from 'vite'; export default defineConfig({ test: { include: ['./tests/**/*.e2e.ts'], setupFiles: ['./tests/setup.js'], }, -}) +}); diff --git a/vitest.config.ts b/vitest.config.ts index c8d30041..5997512b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from 'vite' +import { defineConfig } from 'vite'; export default defineConfig({ test: { @@ -10,4 +10,4 @@ export default defineConfig({ reportsDirectory: './tests/unit/coverage', }, }, -}) +});