From 910f54a8f245a7b65fbc5ec042c3f8e244e1758f Mon Sep 17 00:00:00 2001 From: Marco Comi <9998393+kin0992@users.noreply.github.com> Date: Thu, 8 Feb 2024 15:46:57 +0100 Subject: [PATCH] [DEV-1362] Add BuildEnv and Strapi utilities (#614) --- .changeset/twenty-spoons-prove.md | 5 + apps/nextjs-website/src/BuildConfig.ts | 23 ++++ apps/nextjs-website/src/BuildEnv.ts | 13 ++ .../nextjs-website/src/lib/strapi/StapiEnv.ts | 7 ++ .../src/lib/strapi/StrapiConfig.ts | 8 ++ .../strapi/__tests__/fetchFromStrapi.test.ts | 116 ++++++++++++++++++ .../src/lib/strapi/fetchFromStrapi.ts | 62 ++++++++++ 7 files changed, 234 insertions(+) create mode 100644 .changeset/twenty-spoons-prove.md create mode 100644 apps/nextjs-website/src/BuildConfig.ts create mode 100644 apps/nextjs-website/src/BuildEnv.ts create mode 100644 apps/nextjs-website/src/lib/strapi/StapiEnv.ts create mode 100644 apps/nextjs-website/src/lib/strapi/StrapiConfig.ts create mode 100644 apps/nextjs-website/src/lib/strapi/__tests__/fetchFromStrapi.test.ts create mode 100644 apps/nextjs-website/src/lib/strapi/fetchFromStrapi.ts diff --git a/.changeset/twenty-spoons-prove.md b/.changeset/twenty-spoons-prove.md new file mode 100644 index 0000000000..dcde1b2268 --- /dev/null +++ b/.changeset/twenty-spoons-prove.md @@ -0,0 +1,5 @@ +--- +"nextjs-website": minor +--- + +[DEV-1362] Create BuildEnv and create function to use to make API calls to Strapi diff --git a/apps/nextjs-website/src/BuildConfig.ts b/apps/nextjs-website/src/BuildConfig.ts new file mode 100644 index 0000000000..453d52004b --- /dev/null +++ b/apps/nextjs-website/src/BuildConfig.ts @@ -0,0 +1,23 @@ +import { pipe } from 'fp-ts/lib/function'; +import * as E from 'fp-ts/lib/Either'; +import * as PR from 'io-ts/lib/PathReporter'; +import * as t from 'io-ts'; +import * as tt from 'io-ts-types'; +import { StrapiConfig } from '@/lib/strapi/StrapiConfig'; + +const BuildConfigCodec = t.intersection([ + t.type({ + FETCH_FROM_STRAPI: t.string.pipe(tt.BooleanFromString), + }), + StrapiConfig, +]); + +export type BuildConfig = t.TypeOf; + +export const makeBuildConfig = ( + env: Record +): E.Either => + pipe( + BuildConfigCodec.decode(env), + E.mapLeft((errors) => PR.failure(errors).join('\n')) + ); diff --git a/apps/nextjs-website/src/BuildEnv.ts b/apps/nextjs-website/src/BuildEnv.ts new file mode 100644 index 0000000000..54a4f62010 --- /dev/null +++ b/apps/nextjs-website/src/BuildEnv.ts @@ -0,0 +1,13 @@ +import { BuildConfig } from '@/BuildConfig'; +import { StrapiEnv } from '@/lib/strapi/StapiEnv'; + +// BuildEnv +export type BuildEnv = { + readonly config: BuildConfig; +} & StrapiEnv; + +// given environment variables produce an BuildEnv +export const makeBuildEnv = (config: BuildConfig): BuildEnv => ({ + config, + fetchFun: fetch, +}); diff --git a/apps/nextjs-website/src/lib/strapi/StapiEnv.ts b/apps/nextjs-website/src/lib/strapi/StapiEnv.ts new file mode 100644 index 0000000000..89ce7dd072 --- /dev/null +++ b/apps/nextjs-website/src/lib/strapi/StapiEnv.ts @@ -0,0 +1,7 @@ +import { StrapiConfig } from '@/lib/strapi/StrapiConfig'; + +// This type represents the environment of Strapi. +export type StrapiEnv = { + readonly config: StrapiConfig; + readonly fetchFun: typeof fetch; +}; diff --git a/apps/nextjs-website/src/lib/strapi/StrapiConfig.ts b/apps/nextjs-website/src/lib/strapi/StrapiConfig.ts new file mode 100644 index 0000000000..82f3db84bc --- /dev/null +++ b/apps/nextjs-website/src/lib/strapi/StrapiConfig.ts @@ -0,0 +1,8 @@ +import * as t from 'io-ts'; + +export const StrapiConfig = t.type({ + STRAPI_ENDPOINT: t.string, + STRAPI_API_TOKEN: t.string, +}); + +export type StrapiConfig = t.TypeOf; diff --git a/apps/nextjs-website/src/lib/strapi/__tests__/fetchFromStrapi.test.ts b/apps/nextjs-website/src/lib/strapi/__tests__/fetchFromStrapi.test.ts new file mode 100644 index 0000000000..d8961140e4 --- /dev/null +++ b/apps/nextjs-website/src/lib/strapi/__tests__/fetchFromStrapi.test.ts @@ -0,0 +1,116 @@ +import * as t from 'io-ts'; +import { fetchFromStrapi } from '@/lib/strapi/fetchFromStrapi'; + +const makeTestEnv = () => { + const fetchMock = jest.fn(); + return { + fetchMock, + env: { + config: { + STRAPI_ENDPOINT: 'aStrapiEndpoint', + STRAPI_API_TOKEN: 'aStrapiApiToken', + }, + fetchFun: fetchMock, + }, + }; +}; + +const strapiResponses = { + 200: { + data: { + id: 1, + attributes: { + createdAt: '2024-02-08T11:12:02.142Z', + updatedAt: '2024-02-08T11:12:21.438Z', + publishedAt: '2024-02-08T11:12:21.436Z', + }, + }, + meta: {}, + }, + 404: { + data: null, + error: { + status: 404, + name: 'NotFoundError', + message: 'Not Found', + details: {}, + }, + }, + 401: { + data: null, + error: { + status: 401, + name: 'UnauthorizedError', + message: 'Missing or invalid credentials', + details: {}, + }, + }, +}; + +const codec = t.strict({ + data: t.strict({ + id: t.number, + }), +}); +// This codec is used to test cases when the decode function fails +const badCodec = t.strict({ + data: t.strict({ + id: t.string, + }), +}); + +describe('fetchFromStrapi', () => { + it('should return strapi response given a 200 response', async () => { + const { env, fetchMock } = makeTestEnv(); + fetchMock.mockResolvedValueOnce({ + status: 200, + statusText: 'OK', + json: () => Promise.resolve(strapiResponses[200]), + }); + const actual = fetchFromStrapi('aPath', 'aPopulate', codec)(env); + const expected = { data: { id: 1 } }; + expect(await actual).toStrictEqual(expected); + }); + it('should return error given a 401 response', async () => { + const { env, fetchMock } = makeTestEnv(); + fetchMock.mockResolvedValueOnce({ + status: 401, + statusText: 'Unauthorized', + json: () => Promise.resolve(strapiResponses[401]), + }); + const actual = fetchFromStrapi('aPath', 'aPopulate', codec)(env); + const expected = new Error('401 - Unauthorized'); + await expect(actual).rejects.toStrictEqual(expected); + }); + it('should return error given a 404 response', async () => { + const { env, fetchMock } = makeTestEnv(); + fetchMock.mockResolvedValueOnce({ + status: 404, + statusText: 'Not Found', + json: () => Promise.resolve(strapiResponses[404]), + }); + const actual = fetchFromStrapi('aPath', 'aPopulate', codec)(env); + const expected = new Error('404 - Not Found'); + await expect(actual).rejects.toStrictEqual(expected); + }); + it('should return error given a reject', async () => { + const { env, fetchMock } = makeTestEnv(); + fetchMock.mockRejectedValueOnce({}); + const actual = fetchFromStrapi('aPath', 'aPopulate', codec)(env); + const expected = new Error('[object Object]'); + await expect(actual).rejects.toStrictEqual(expected); + }); + it('should return error given a decode error', async () => { + const { env, fetchMock } = makeTestEnv(); + fetchMock.mockResolvedValueOnce({ + status: 200, + statusText: 'OK', + json: () => Promise.resolve(strapiResponses[200]), + }); + const actual = fetchFromStrapi('aPath', 'aPopulate', badCodec)(env); + const expected = new Error( + 'Invalid value 1 supplied to : {| data: {| id: string |} |}/data: {| id: string |}/id: string' + ); + await expect(actual).rejects.toStrictEqual(expected); + }); +}); diff --git a/apps/nextjs-website/src/lib/strapi/fetchFromStrapi.ts b/apps/nextjs-website/src/lib/strapi/fetchFromStrapi.ts new file mode 100644 index 0000000000..bfff04626e --- /dev/null +++ b/apps/nextjs-website/src/lib/strapi/fetchFromStrapi.ts @@ -0,0 +1,62 @@ +import * as t from 'io-ts'; +import { pipe } from 'fp-ts/lib/function'; +import * as R from 'fp-ts/lib/Reader'; +import * as E from 'fp-ts/lib/Either'; +import * as TE from 'fp-ts/lib/TaskEither'; +import * as PR from 'io-ts/lib/PathReporter'; +import { StrapiEnv } from '@/lib/strapi/StapiEnv'; + +// Function to invoke in order to retrieve data from Strapi. +export const fetchFromStrapi = ( + path: string, + populate: '*' | string, + codec: t.Type +) => + pipe( + R.ask(), + R.map( + ({ + config: { + STRAPI_ENDPOINT: strapiEndpoint, + STRAPI_API_TOKEN: strapiApiToken, + }, + fetchFun, + }) => + pipe( + // handle any promise result + TE.tryCatch( + () => + fetchFun(`${strapiEndpoint}/api/${path}/?populate=${populate}`, { + method: 'GET', + headers: { + Authorization: `Bearer ${strapiApiToken}`, + }, + }), + E.toError + ), + TE.chain((response) => { + if (response.status === 200) { + return TE.tryCatch(() => response.json(), E.toError); + } else { + return TE.left(makeError(response)); + } + }), + TE.chainEitherK((json) => + // decode the response with the given codec + pipe( + codec.decode(json), + E.mapLeft((errors) => new Error(PR.failure(errors).join('\n'))) + ) + ), + TE.fold( + // eslint-disable-next-line functional/no-promise-reject + (errors) => () => Promise.reject(errors), + (result) => () => Promise.resolve(result) + ) + )() + ) + ); + +const makeError = ({ status, statusText }: Response) => { + return new Error(`${status} - ${statusText}`); +};