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..6fe12089 --- /dev/null +++ b/src/graphql-wrapper.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +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' }; + + 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..45e1d150 --- /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; + + // 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/setup.js b/tests/setup.js index 855c2532..06a37d55 100644 --- a/tests/setup.js +++ b/tests/setup.js @@ -1 +1,8 @@ -import 'isomorphic-fetch' +import 'isomorphic-fetch'; +import createFetchMock from 'vitest-fetch-mock'; +import { vi } from 'vitest'; + +const fetchMocker = createFetchMock(vi); + +// sets globalThis.fetch and globalThis.fetchMock to our mocked version +fetchMocker.enableMocks(); 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', }, }, -}) +});