From dafd070cc7b38489ad0caa1e8355c2e92a5c7013 Mon Sep 17 00:00:00 2001 From: Wojciech Sikora Date: Thu, 22 Feb 2024 10:44:00 +0100 Subject: [PATCH 1/8] feat: default behaviour of HTTP Client --- packages/sdk/jest.config.integration.ts | 5 +- packages/sdk/package.json | 7 +- .../__tests__/__mocks__/apiClient/server.js | 23 +++++++ .../__tests__/__mocks__/apiClient/types.ts | 6 ++ .../__config__/jest.setup.global.ts | 22 ++++++ .../__config__/jest.teardown.global.ts | 3 + .../modules/moduleFromEndpoints.spec.ts | 68 +++++++++++++++++-- .../modules/moduleFromEndpoints/connector.ts | 20 ++++-- .../moduleFromEndpoints/getHttpClient.ts | 27 ++++++++ .../src/modules/moduleFromEndpoints/module.ts | 8 ++- .../src/modules/moduleFromEndpoints/types.ts | 47 +++++++++++++ yarn.lock | 25 +++++++ 12 files changed, 243 insertions(+), 18 deletions(-) create mode 100644 packages/sdk/src/__tests__/__mocks__/apiClient/server.js create mode 100644 packages/sdk/src/__tests__/__mocks__/apiClient/types.ts create mode 100644 packages/sdk/src/__tests__/integration/__config__/jest.setup.global.ts create mode 100644 packages/sdk/src/__tests__/integration/__config__/jest.teardown.global.ts create mode 100644 packages/sdk/src/modules/moduleFromEndpoints/getHttpClient.ts diff --git a/packages/sdk/jest.config.integration.ts b/packages/sdk/jest.config.integration.ts index 64f8b965f3..773aea3853 100644 --- a/packages/sdk/jest.config.integration.ts +++ b/packages/sdk/jest.config.integration.ts @@ -2,7 +2,8 @@ import { baseConfig } from "@vue-storefront/jest-config"; export default { ...baseConfig, - // globalSetup: "./__tests__/integration/__config__/jest.setup.global.ts", - // globalTeardown: "./__tests__/integration/__config__/jest.teardown.global.ts", + globalSetup: "./src/__tests__/integration/__config__/jest.setup.global.ts", + globalTeardown: + "./src/__tests__/integration/__config__/jest.teardown.global.ts", // setupFilesAfterEnv: ["./__tests__/integration/__config__/jest.setup.ts"], }; diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 2ec3dc5382..16b7a1edb3 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -26,7 +26,12 @@ "babel-preset-node": "^5.1.1", "nodemon": "^2.0.20", "ts-jest": "^29.0.2", - "ts-node-dev": "^2.0.0" + "ts-node-dev": "^2.0.0", + "@vue-storefront/middleware": "*", + "@types/isomorphic-fetch": "^0.0.39" + }, + "dependencies": { + "isomorphic-fetch": "^3.0.0" }, "engines": { "npm": ">=7.0.0", diff --git a/packages/sdk/src/__tests__/__mocks__/apiClient/server.js b/packages/sdk/src/__tests__/__mocks__/apiClient/server.js new file mode 100644 index 0000000000..b7ccd8911d --- /dev/null +++ b/packages/sdk/src/__tests__/__mocks__/apiClient/server.js @@ -0,0 +1,23 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +const { apiClientFactory } = require("@vue-storefront/middleware"); + +const onCreate = (settings) => { + return { + config: settings, + client: null, + }; +}; + +const { createApiClient } = apiClientFactory({ + onCreate, + api: { + getProduct: async (_context, _params) => { + return { id: 1, name: "Test Product" }; + }, + getProducts: async (_context, _params) => { + return [{ id: 1, name: "Test Product" }]; + }, + }, +}); + +exports.createApiClient = createApiClient; diff --git a/packages/sdk/src/__tests__/__mocks__/apiClient/types.ts b/packages/sdk/src/__tests__/__mocks__/apiClient/types.ts new file mode 100644 index 0000000000..491abd6a60 --- /dev/null +++ b/packages/sdk/src/__tests__/__mocks__/apiClient/types.ts @@ -0,0 +1,6 @@ +export type Endpoints = { + getProduct: (params: { id: number }) => Promise<{ id: number; name: string }>; + getProducts: (params: { + limit: number; + }) => Promise<{ id: number; name: string }[]>; +}; diff --git a/packages/sdk/src/__tests__/integration/__config__/jest.setup.global.ts b/packages/sdk/src/__tests__/integration/__config__/jest.setup.global.ts new file mode 100644 index 0000000000..4e7960608f --- /dev/null +++ b/packages/sdk/src/__tests__/integration/__config__/jest.setup.global.ts @@ -0,0 +1,22 @@ +import { createServer } from "@vue-storefront/middleware"; + +async function runMiddleware(app) { + return new Promise((resolve) => { + const server = app.listen(8181, async () => { + resolve(server); + }); + }); +} + +export default async () => { + const app = await createServer({ + integrations: { + commerce: { + location: "./src/__tests__/__mocks__/apiClient/server", + configuration: {}, + }, + }, + }); + const server = await runMiddleware(app); + globalThis.__MIDDLEWARE__ = server; +}; diff --git a/packages/sdk/src/__tests__/integration/__config__/jest.teardown.global.ts b/packages/sdk/src/__tests__/integration/__config__/jest.teardown.global.ts new file mode 100644 index 0000000000..770b59ad56 --- /dev/null +++ b/packages/sdk/src/__tests__/integration/__config__/jest.teardown.global.ts @@ -0,0 +1,3 @@ +export default () => { + globalThis.__MIDDLEWARE__.close(); +}; diff --git a/packages/sdk/src/__tests__/integration/modules/moduleFromEndpoints.spec.ts b/packages/sdk/src/__tests__/integration/modules/moduleFromEndpoints.spec.ts index 2bf6a8c45a..6dc99d8433 100644 --- a/packages/sdk/src/__tests__/integration/modules/moduleFromEndpoints.spec.ts +++ b/packages/sdk/src/__tests__/integration/modules/moduleFromEndpoints.spec.ts @@ -1,15 +1,14 @@ +import "isomorphic-fetch"; import { initSDK, buildModule } from "../../../index"; import { moduleFromEndpoints } from "../../../modules/moduleFromEndpoints"; - -type Endpoints = { - getProduct: (params: { id: string }) => Promise; - getProducts: (params: { limit: number }) => Promise; -}; +import { Endpoints } from "../../__mocks__/apiClient/types"; describe("moduleFromEndpoints", () => { it("should be able to be used as standard SDK module", async () => { const sdkConfig = { - commerce: buildModule(moduleFromEndpoints), + commerce: buildModule(moduleFromEndpoints, { + apiUrl: "http://localhost:8181/commerce", + }), }; const sdk = initSDK(sdkConfig); @@ -19,7 +18,9 @@ describe("moduleFromEndpoints", () => { it("should use generic types to define the endpoints", async () => { const sdkConfig = { - commerce: buildModule(moduleFromEndpoints), + commerce: buildModule(moduleFromEndpoints, { + apiUrl: "http://localhost:8181/commerce", + }), }; const sdk = initSDK(sdkConfig); @@ -27,4 +28,57 @@ describe("moduleFromEndpoints", () => { expect(sdk.commerce.getProduct).toBeDefined(); expect(sdk.commerce.getProducts).toBeDefined(); }); + + it("should allow to override the default HTTP Client", async () => { + const customHttpClient = jest + .fn() + .mockResolvedValue({ id: 1, name: "Test Product" }); + const sdkConfig = { + commerce: buildModule(moduleFromEndpoints, { + apiUrl: "http://localhost:8181/commerce", + httpClient: customHttpClient, + }), + }; + const sdk = initSDK(sdkConfig); + + await sdk.commerce.getProduct({ id: 1 }); + + expect(customHttpClient).toHaveBeenCalled(); + }); + + it("should send a POST request to / by default", async () => { + const customHttpClient = jest + .fn() + .mockResolvedValue({ id: 1, name: "Test Product" }); + const sdkConfig = { + commerce: buildModule(moduleFromEndpoints, { + apiUrl: "http://localhost:8181/commerce", + httpClient: customHttpClient, + }), + }; + const sdk = initSDK(sdkConfig); + + await sdk.commerce.getProduct({ id: 1 }); + + expect(customHttpClient).toHaveBeenCalledWith( + "http://localhost:8181/commerce/getProduct", + { + method: "POST", + params: [{ id: 1 }], + } + ); + }); + + it("should use default HTTP Client if it's not provided", async () => { + const sdkConfig = { + commerce: buildModule(moduleFromEndpoints, { + apiUrl: "http://localhost:8181/commerce", + }), + }; + const sdk = initSDK(sdkConfig); + + const response = await sdk.commerce.getProduct({ id: 1 }); + + expect(response).toEqual({ id: 1, name: "Test Product" }); + }); }); diff --git a/packages/sdk/src/modules/moduleFromEndpoints/connector.ts b/packages/sdk/src/modules/moduleFromEndpoints/connector.ts index 019eb1271b..55025925b1 100644 --- a/packages/sdk/src/modules/moduleFromEndpoints/connector.ts +++ b/packages/sdk/src/modules/moduleFromEndpoints/connector.ts @@ -1,13 +1,23 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { Connector } from "../../types"; -import { EndpointsConstraint } from "./types"; +import { getHTTPClient } from "./getHttpClient"; +import { EndpointsConstraint, Options } from "./types"; -export const connector = () => { +export const connector = ( + options: Options +) => { + const httpClient = getHTTPClient(options); const target = {} as Endpoints; return new Proxy(target, { - get: (_, endpoint) => { - return async (params: unknown[]) => { - return {}; + get: (_, methodName) => { + if (typeof methodName !== "string") { + throw new Error("Method must be a string"); + } + + return async (...params: unknown[]) => { + return httpClient(methodName, { + params, + }); }; }, }) satisfies Connector; diff --git a/packages/sdk/src/modules/moduleFromEndpoints/getHttpClient.ts b/packages/sdk/src/modules/moduleFromEndpoints/getHttpClient.ts new file mode 100644 index 0000000000..9f2c430780 --- /dev/null +++ b/packages/sdk/src/modules/moduleFromEndpoints/getHttpClient.ts @@ -0,0 +1,27 @@ +import { Options } from "./types"; + +export const getHTTPClient = (options: Options) => { + const getUrl = (methodName: string) => { + return `${options.apiUrl}/${methodName}`; + }; + + const getConfig = (config: any) => { + return { + ...config, + method: "POST", + }; + }; + + const customHttpClient = options.httpClient; + + const defaultHTTPClient = async (url: string, config: any) => { + const response = await fetch(url, config); + return response.json(); + }; + + const httpClient = customHttpClient || defaultHTTPClient; + + return async (methodName: string, config: any) => { + return httpClient(getUrl(methodName), getConfig(config)); + }; +}; diff --git a/packages/sdk/src/modules/moduleFromEndpoints/module.ts b/packages/sdk/src/modules/moduleFromEndpoints/module.ts index 7eee75bf2f..5b0ddbd791 100644 --- a/packages/sdk/src/modules/moduleFromEndpoints/module.ts +++ b/packages/sdk/src/modules/moduleFromEndpoints/module.ts @@ -1,8 +1,10 @@ import { Module } from "../../types"; import { connector } from "./connector"; -import { EndpointsConstraint } from "./types"; +import { EndpointsConstraint, Options } from "./types"; -export const moduleFromEndpoints = () => +export const moduleFromEndpoints = ( + options: Options +) => ({ - connector: connector(), + connector: connector(options), } satisfies Module); diff --git a/packages/sdk/src/modules/moduleFromEndpoints/types.ts b/packages/sdk/src/modules/moduleFromEndpoints/types.ts index 0f909c2d0f..5b1cac809d 100644 --- a/packages/sdk/src/modules/moduleFromEndpoints/types.ts +++ b/packages/sdk/src/modules/moduleFromEndpoints/types.ts @@ -15,6 +15,20 @@ export type EndpointsConstraint = { [key: string]: AnyFunction; }; +/** + * HTTP Client abstraction. + */ +export type HTTPClient = ( + /** + * URL for the request. + */ + url: string, + /** + * Config for the request. + */ + config: any +) => Promise; + /** * Options for the `moduleFromEndpoints`. */ @@ -23,4 +37,37 @@ export interface Options { * Base URL for the API. */ apiUrl: string; + + /** + * Custom HTTP Client. + * + * It's optional and it will use the default HTTP Client if it's not provided. + * + * @example + * Using `axios` as the HTTP Client + * ```ts + * import axios from "axios"; + * + * const options: Options = { + * apiUrl: "https://api.example.com", + * httpClient: (url, config) => { + * if (config.method === "GET") { + * const queryParams = new URLSearchParams(config.params); + * const urlWithParams = new URL(url); + * urlWithParams.search = queryParams.toString(); + * return axios({ + * ...config, + * url: urlWithParams.toString(), + * }); + * } + * + * return axios({ + * ...config, + * url, + * }); + * }, + * }; + * ``` + */ + httpClient?: HTTPClient; } diff --git a/yarn.lock b/yarn.lock index 737c1b3910..d4624ceb2a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2489,6 +2489,11 @@ dependencies: ci-info "^3.1.0" +"@types/isomorphic-fetch@^0.0.39": + version "0.0.39" + resolved "https://registry.yarnpkg.com/@types/isomorphic-fetch/-/isomorphic-fetch-0.0.39.tgz#889573a72ca637bc1a665910a41ff1cb3b52011f" + integrity sha512-I0gou/ZdA1vMG7t7gMzL7VYu2xAKU78rW9U1l10MI0nn77pEHq3tQqHQ8hMmXdMpBlkxZOorjI4sO594Z3kKJw== + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": version "2.0.4" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz#8467d4b3c087805d63580480890791277ce35c44" @@ -7914,6 +7919,14 @@ isobject@^3.0.0, isobject@^3.0.1: resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" integrity sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg== +isomorphic-fetch@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz#0267b005049046d2421207215d45d6a262b8b8b4" + integrity sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA== + dependencies: + node-fetch "^2.6.1" + whatwg-fetch "^3.4.1" + isomorphic-git@^1.21.0: version "1.25.1" resolved "https://registry.npmjs.org/isomorphic-git/-/isomorphic-git-1.25.1.tgz#120dba31fec0fd45eeb7a82ac6d007581e6d6eb1" @@ -10051,6 +10064,13 @@ node-fetch@2.6.7, node-fetch@^2.6.7: dependencies: whatwg-url "^5.0.0" +node-fetch@^2.6.1: + version "2.7.0" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" + integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== + dependencies: + whatwg-url "^5.0.0" + node-gyp-build@^4.3.0: version "4.6.1" resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.6.1.tgz#24b6d075e5e391b8d5539d98c7fc5c210cac8a3e" @@ -13742,6 +13762,11 @@ whatwg-encoding@^1.0.5: dependencies: iconv-lite "0.4.24" +whatwg-fetch@^3.4.1: + version "3.6.20" + resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz#580ce6d791facec91d37c72890995a0b48d31c70" + integrity sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg== + whatwg-mimetype@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz#3d4b1e0312d2079879f826aff18dbeeca5960fbf" From 3ef538257f440bfa38c3d0c53deab63da69da9a7 Mon Sep 17 00:00:00 2001 From: Wojciech Sikora Date: Thu, 22 Feb 2024 11:01:33 +0100 Subject: [PATCH 2/8] remove isomorphic-fetch from depts --- packages/sdk/package.json | 3 --- yarn.lock | 5 ----- 2 files changed, 8 deletions(-) diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 16b7a1edb3..a193d278bd 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -28,9 +28,6 @@ "ts-jest": "^29.0.2", "ts-node-dev": "^2.0.0", "@vue-storefront/middleware": "*", - "@types/isomorphic-fetch": "^0.0.39" - }, - "dependencies": { "isomorphic-fetch": "^3.0.0" }, "engines": { diff --git a/yarn.lock b/yarn.lock index d4624ceb2a..f71cac527e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2489,11 +2489,6 @@ dependencies: ci-info "^3.1.0" -"@types/isomorphic-fetch@^0.0.39": - version "0.0.39" - resolved "https://registry.yarnpkg.com/@types/isomorphic-fetch/-/isomorphic-fetch-0.0.39.tgz#889573a72ca637bc1a665910a41ff1cb3b52011f" - integrity sha512-I0gou/ZdA1vMG7t7gMzL7VYu2xAKU78rW9U1l10MI0nn77pEHq3tQqHQ8hMmXdMpBlkxZOorjI4sO594Z3kKJw== - "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": version "2.0.4" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz#8467d4b3c087805d63580480890791277ce35c44" From de6000e677ab98745ab4817c4584ec5c8e3b8ec2 Mon Sep 17 00:00:00 2001 From: Wojciech Sikora Date: Thu, 22 Feb 2024 12:09:20 +0100 Subject: [PATCH 3/8] full implementation of HTTP client --- .../__tests__/__mocks__/apiClient/types.ts | 6 + .../modules/moduleFromEndpoints.spec.ts | 162 +++++++++++++++++- .../modules/moduleFromEndpoints/connector.ts | 26 ++- .../src/modules/moduleFromEndpoints/consts.ts | 4 + .../moduleFromEndpoints/getHttpClient.ts | 27 --- .../src/modules/moduleFromEndpoints/index.ts | 1 + .../src/modules/moduleFromEndpoints/types.ts | 75 +++++++- .../utils/getHttpClient.ts | 60 +++++++ .../moduleFromEndpoints/utils/index.ts | 2 + .../utils/prepareConfig.ts | 25 +++ 10 files changed, 352 insertions(+), 36 deletions(-) create mode 100644 packages/sdk/src/modules/moduleFromEndpoints/consts.ts delete mode 100644 packages/sdk/src/modules/moduleFromEndpoints/getHttpClient.ts create mode 100644 packages/sdk/src/modules/moduleFromEndpoints/utils/getHttpClient.ts create mode 100644 packages/sdk/src/modules/moduleFromEndpoints/utils/index.ts create mode 100644 packages/sdk/src/modules/moduleFromEndpoints/utils/prepareConfig.ts diff --git a/packages/sdk/src/__tests__/__mocks__/apiClient/types.ts b/packages/sdk/src/__tests__/__mocks__/apiClient/types.ts index 491abd6a60..5800e254e8 100644 --- a/packages/sdk/src/__tests__/__mocks__/apiClient/types.ts +++ b/packages/sdk/src/__tests__/__mocks__/apiClient/types.ts @@ -1,5 +1,11 @@ export type Endpoints = { + /** + * Get the product by id. + */ getProduct: (params: { id: number }) => Promise<{ id: number; name: string }>; + /** + * Get the list of products. + */ getProducts: (params: { limit: number; }) => Promise<{ id: number; name: string }[]>; diff --git a/packages/sdk/src/__tests__/integration/modules/moduleFromEndpoints.spec.ts b/packages/sdk/src/__tests__/integration/modules/moduleFromEndpoints.spec.ts index 6dc99d8433..accc594482 100644 --- a/packages/sdk/src/__tests__/integration/modules/moduleFromEndpoints.spec.ts +++ b/packages/sdk/src/__tests__/integration/modules/moduleFromEndpoints.spec.ts @@ -1,6 +1,9 @@ import "isomorphic-fetch"; import { initSDK, buildModule } from "../../../index"; -import { moduleFromEndpoints } from "../../../modules/moduleFromEndpoints"; +import { + moduleFromEndpoints, + prepareConfig, +} from "../../../modules/moduleFromEndpoints"; import { Endpoints } from "../../__mocks__/apiClient/types"; describe("moduleFromEndpoints", () => { @@ -65,6 +68,10 @@ describe("moduleFromEndpoints", () => { { method: "POST", params: [{ id: 1 }], + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, } ); }); @@ -81,4 +88,157 @@ describe("moduleFromEndpoints", () => { expect(response).toEqual({ id: 1, name: "Test Product" }); }); + + it("should allow to use GET request with query parameters", async () => { + const customHttpClient = jest + .fn() + .mockResolvedValue({ id: 1, name: "Test Product" }); + const sdkConfig = { + commerce: buildModule(moduleFromEndpoints, { + apiUrl: "http://localhost:8181/commerce", + httpClient: customHttpClient, + }), + }; + const sdk = initSDK(sdkConfig); + const expectedUrl = new URL("http://localhost:8181/commerce/getProducts"); + expectedUrl.searchParams.append("body", JSON.stringify([{ limit: 1 }])); + + await sdk.commerce.getProducts( + { limit: 1 }, + prepareConfig({ method: "GET" }) + ); + + expect(customHttpClient).toHaveBeenCalledWith(expectedUrl.toString(), { + method: "GET", + params: [], + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + }); + }); + + it("should normalize the base URL", async () => { + const customHttpClient = jest + .fn() + .mockResolvedValue({ id: 1, name: "Test Product" }); + const sdkConfig = { + commerce: buildModule(moduleFromEndpoints, { + apiUrl: "http://localhost:8181/commerce/", + httpClient: customHttpClient, + }), + }; + const sdk = initSDK(sdkConfig); + + await sdk.commerce.getProduct({ id: 1 }); + + expect(customHttpClient).toHaveBeenCalledWith( + "http://localhost:8181/commerce/getProduct", + { + method: "POST", + params: [{ id: 1 }], + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + } + ); + }); + + it("should allow to use custom headers", async () => { + const customHttpClient = jest + .fn() + .mockResolvedValue({ id: 1, name: "Test Product" }); + const sdkConfig = { + commerce: buildModule(moduleFromEndpoints, { + apiUrl: "http://localhost:8181/commerce", + httpClient: customHttpClient, + }), + }; + const sdk = initSDK(sdkConfig); + + await sdk.commerce.getProduct( + { id: 1 }, + prepareConfig({ + method: "POST", + headers: { + "X-Test": "x-test-header", + }, + }) + ); + + expect(customHttpClient).toHaveBeenCalledWith( + "http://localhost:8181/commerce/getProduct", + { + method: "POST", + params: [{ id: 1 }], + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "X-Test": "x-test-header", + }, + } + ); + }); + + it("should allow to define default headers", async () => { + const customHttpClient = jest + .fn() + .mockResolvedValue({ id: 1, name: "Test Product" }); + const sdkConfig = { + commerce: buildModule(moduleFromEndpoints, { + apiUrl: "http://localhost:8181/commerce", + httpClient: customHttpClient, + defaultRequestConfig: { + headers: { + "X-Test": "x-test-header", + }, + }, + }), + }; + const sdk = initSDK(sdkConfig); + + await sdk.commerce.getProduct({ id: 1 }); + + expect(customHttpClient).toHaveBeenCalledWith( + "http://localhost:8181/commerce/getProduct", + { + method: "POST", + params: [{ id: 1 }], + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "X-Test": "x-test-header", + }, + } + ); + }); + + it("should use different base URL during SSR if defined", async () => { + const customHttpClient = jest + .fn() + .mockResolvedValue({ id: 1, name: "Test Product" }); + const sdkConfig = { + commerce: buildModule(moduleFromEndpoints, { + apiUrl: "/api/commerce", + ssrApiUrl: "http://localhost:8181/commerce", + httpClient: customHttpClient, + }), + }; + const sdk = initSDK(sdkConfig); + + await sdk.commerce.getProduct({ id: 1 }); + + expect(customHttpClient).toHaveBeenCalledWith( + "http://localhost:8181/commerce/getProduct", + { + method: "POST", + params: [{ id: 1 }], + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + } + ); + }); }); diff --git a/packages/sdk/src/modules/moduleFromEndpoints/connector.ts b/packages/sdk/src/modules/moduleFromEndpoints/connector.ts index 55025925b1..266faa7245 100644 --- a/packages/sdk/src/modules/moduleFromEndpoints/connector.ts +++ b/packages/sdk/src/modules/moduleFromEndpoints/connector.ts @@ -1,21 +1,37 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { Connector } from "../../types"; -import { getHTTPClient } from "./getHttpClient"; -import { EndpointsConstraint, Options } from "./types"; +import { getHTTPClient } from "./utils/getHttpClient"; +import { + EndpointsConstraint, + Options, + Methods, + RequestConfig, + MethodConfig, +} from "./types"; +import { isRequestConfig } from "./consts"; export const connector = ( options: Options ) => { const httpClient = getHTTPClient(options); - const target = {} as Endpoints; - return new Proxy(target, { + const target = {} as Methods; + return new Proxy>(target, { get: (_, methodName) => { if (typeof methodName !== "string") { throw new Error("Method must be a string"); } - return async (...params: unknown[]) => { + return async (...params: any[]) => { + let requestConfig: RequestConfig | undefined; + const methodConfig: MethodConfig = params[params.length - 1]; + + if (methodConfig?.[isRequestConfig]) { + const { [isRequestConfig]: omit, ...rest } = params.pop(); + requestConfig = rest; + } + return httpClient(methodName, { + ...requestConfig, params, }); }; diff --git a/packages/sdk/src/modules/moduleFromEndpoints/consts.ts b/packages/sdk/src/modules/moduleFromEndpoints/consts.ts new file mode 100644 index 0000000000..788cabe835 --- /dev/null +++ b/packages/sdk/src/modules/moduleFromEndpoints/consts.ts @@ -0,0 +1,4 @@ +/** + * Symbol to mark a method config object. + */ +export const isRequestConfig = Symbol("methodConfig"); diff --git a/packages/sdk/src/modules/moduleFromEndpoints/getHttpClient.ts b/packages/sdk/src/modules/moduleFromEndpoints/getHttpClient.ts deleted file mode 100644 index 9f2c430780..0000000000 --- a/packages/sdk/src/modules/moduleFromEndpoints/getHttpClient.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Options } from "./types"; - -export const getHTTPClient = (options: Options) => { - const getUrl = (methodName: string) => { - return `${options.apiUrl}/${methodName}`; - }; - - const getConfig = (config: any) => { - return { - ...config, - method: "POST", - }; - }; - - const customHttpClient = options.httpClient; - - const defaultHTTPClient = async (url: string, config: any) => { - const response = await fetch(url, config); - return response.json(); - }; - - const httpClient = customHttpClient || defaultHTTPClient; - - return async (methodName: string, config: any) => { - return httpClient(getUrl(methodName), getConfig(config)); - }; -}; diff --git a/packages/sdk/src/modules/moduleFromEndpoints/index.ts b/packages/sdk/src/modules/moduleFromEndpoints/index.ts index eeac135c9c..7c3958f882 100644 --- a/packages/sdk/src/modules/moduleFromEndpoints/index.ts +++ b/packages/sdk/src/modules/moduleFromEndpoints/index.ts @@ -1,2 +1,3 @@ export { moduleFromEndpoints } from "./module"; +export { getHTTPClient, prepareConfig } from "./utils"; export * from "./types"; diff --git a/packages/sdk/src/modules/moduleFromEndpoints/types.ts b/packages/sdk/src/modules/moduleFromEndpoints/types.ts index 5b1cac809d..4515ffc3e7 100644 --- a/packages/sdk/src/modules/moduleFromEndpoints/types.ts +++ b/packages/sdk/src/modules/moduleFromEndpoints/types.ts @@ -1,4 +1,5 @@ import { AnyFunction } from "../../types"; +import { isRequestConfig } from "./consts"; /** * Constraint for the endpoints. @@ -15,6 +16,40 @@ export type EndpointsConstraint = { [key: string]: AnyFunction; }; +/** + * Config for the request. + */ +export interface RequestConfig { + /** + * Headers for the request. + */ + headers?: Record; + /** + * HTTP method for the request. + */ + method?: "GET" | "POST"; +} + +/** + * Config for the HTTP client. + */ +export interface HTTPClientConfig extends RequestConfig { + /** + * Parameters for the request. + */ + params?: any[]; +} + +/** + * Config for the SDK method. + */ +export interface MethodConfig extends RequestConfig { + /** + * It's used to differentiate the method config from the params. + */ + [isRequestConfig]: boolean; +} + /** * HTTP Client abstraction. */ @@ -26,18 +61,33 @@ export type HTTPClient = ( /** * Config for the request. */ - config: any + config: HTTPClientConfig ) => Promise; /** * Options for the `moduleFromEndpoints`. */ -export interface Options { +export type Options = { /** * Base URL for the API. */ apiUrl: string; + /** + * Base URL for the API in the server side rendering. + * It's optional and it will use the `apiUrl` if it's not provided. + * + * @remarks + * This may be useful during implementation of a multi-store feature based on domains. + * + * `apiUrl` could be set to `/api` and on the client side, the HTTP Client would use the current domain. + * + * However, on the server side, the HTTP Client is not aware of the current domain, so it would use just the `/api` path and it would fail. + * + * To solve this issue, the `ssrApiUrl` could be set to the pod name with port (e.g. `https://localhost:8181`) and the HTTP Client would use it on the server side. + */ + ssrApiUrl?: string; + /** * Custom HTTP Client. * @@ -70,4 +120,23 @@ export interface Options { * ``` */ httpClient?: HTTPClient; -} + + /** + * Default request config for each request. + */ + defaultRequestConfig?: RequestConfig; +}; + +/** + * Final type for the SDK methods. + * + * It requires the `Endpoints` interface to be provided. + * Based on this interface it will generate the methods with the correct parameters and return types. + * + * To each endpoint, it will add the `config` parameter with the `MethodConfig` type. + */ +export type Methods = { + [Key in keyof Endpoints]: ( + ...params: [...Parameters, config?: MethodConfig] + ) => ReturnType; +}; diff --git a/packages/sdk/src/modules/moduleFromEndpoints/utils/getHttpClient.ts b/packages/sdk/src/modules/moduleFromEndpoints/utils/getHttpClient.ts new file mode 100644 index 0000000000..7b2c388ec5 --- /dev/null +++ b/packages/sdk/src/modules/moduleFromEndpoints/utils/getHttpClient.ts @@ -0,0 +1,60 @@ +import { Options, HTTPClientConfig } from "../types"; + +export const getHTTPClient = (options: Options) => { + const getUrl = (path: string, config: HTTPClientConfig): string => { + const { apiUrl, ssrApiUrl } = options; + const { method = "POST", params = [] } = config; + + // Determine the base URL based on the environment + const baseUrl = + typeof window === "undefined" ? ssrApiUrl || apiUrl : apiUrl; + + // Ensure the base URL ends with a slash + const normalizedBaseUrl = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`; + const url = `${normalizedBaseUrl}${path}`; + const queryParams = method === "GET" ? params : undefined; + + // If there are no query params, return the URL as is + if (!queryParams) { + return url; + } + + // If there are query params, append them to the URL as `?body=[]` + const queryParamsString = JSON.stringify(queryParams); + const urlWithParams = new URL(url); + urlWithParams.searchParams.append("body", queryParamsString); + + return urlWithParams.toString(); + }; + + const getConfig = (config: HTTPClientConfig): HTTPClientConfig => { + const { defaultRequestConfig = {} } = options; + const { method = "POST", headers = {}, params = [] } = config; + const defaultHeaders = { + "Content-Type": "application/json", + Accept: "application/json", + ...defaultRequestConfig.headers, + }; + + return { + ...config, + params: method === "GET" ? [] : params, + method, + headers: { + ...defaultHeaders, + ...headers, + }, + }; + }; + + const defaultHTTPClient = async (url: string, config: HTTPClientConfig) => { + const response = await fetch(url, config); + return response.json(); + }; + + return async (methodName: string, config: HTTPClientConfig) => { + const httpClient = options.httpClient || defaultHTTPClient; + + return httpClient(getUrl(methodName, config), getConfig(config)); + }; +}; diff --git a/packages/sdk/src/modules/moduleFromEndpoints/utils/index.ts b/packages/sdk/src/modules/moduleFromEndpoints/utils/index.ts new file mode 100644 index 0000000000..3527cb37fe --- /dev/null +++ b/packages/sdk/src/modules/moduleFromEndpoints/utils/index.ts @@ -0,0 +1,2 @@ +export { getHTTPClient } from "./getHttpClient"; +export { prepareConfig } from "./prepareConfig"; diff --git a/packages/sdk/src/modules/moduleFromEndpoints/utils/prepareConfig.ts b/packages/sdk/src/modules/moduleFromEndpoints/utils/prepareConfig.ts new file mode 100644 index 0000000000..0b0bc836b5 --- /dev/null +++ b/packages/sdk/src/modules/moduleFromEndpoints/utils/prepareConfig.ts @@ -0,0 +1,25 @@ +import { isRequestConfig } from "../consts"; +import { MethodConfig, RequestConfig } from "../types"; + +/** + * Prepare the config for the request. + * It's used to differentiate the method config from the params. + * + * @example + * Usage + * ```ts + * import { prepareConfig } from "@vue-storefront/sdk"; + * + * const products = sdk.commerce.getProducts(params, prepareConfig({ method: "GET" })); + * ``` + */ +export const prepareConfig = < + CustomConfig extends RequestConfig = RequestConfig +>( + requestConfig: CustomConfig +): MethodConfig => { + return { + ...requestConfig, + [isRequestConfig]: true, + }; +}; From 372ce5d868a06db61c06c25bb00a57b8021b8d7a Mon Sep 17 00:00:00 2001 From: Wojciech Sikora Date: Thu, 29 Feb 2024 16:08:02 +0100 Subject: [PATCH 4/8] test: update tests based on the review --- .../modules/moduleFromEndpoints.spec.ts | 79 ++++++------------- 1 file changed, 22 insertions(+), 57 deletions(-) diff --git a/packages/sdk/src/__tests__/integration/modules/moduleFromEndpoints.spec.ts b/packages/sdk/src/__tests__/integration/modules/moduleFromEndpoints.spec.ts index 85dee0388e..2a3f71e982 100644 --- a/packages/sdk/src/__tests__/integration/modules/moduleFromEndpoints.spec.ts +++ b/packages/sdk/src/__tests__/integration/modules/moduleFromEndpoints.spec.ts @@ -33,9 +33,7 @@ describe("moduleFromEndpoints", () => { }); it("should allow to override the default HTTP Client", async () => { - const customHttpClient = jest - .fn() - .mockResolvedValue({ id: 1, name: "Test Product" }); + const customHttpClient = jest.fn(); const sdkConfig = { commerce: buildModule(moduleFromEndpoints, { apiUrl: "http://localhost:8181/commerce", @@ -50,9 +48,7 @@ describe("moduleFromEndpoints", () => { }); it("should send a POST request to / by default", async () => { - const customHttpClient = jest - .fn() - .mockResolvedValue({ id: 1, name: "Test Product" }); + const customHttpClient = jest.fn(); const sdkConfig = { commerce: buildModule(moduleFromEndpoints, { apiUrl: "http://localhost:8181/commerce", @@ -65,14 +61,10 @@ describe("moduleFromEndpoints", () => { expect(customHttpClient).toHaveBeenCalledWith( "http://localhost:8181/commerce/getProduct", - { + expect.objectContaining({ method: "POST", params: [{ id: 1 }], - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - } + }) ); }); @@ -108,20 +100,17 @@ describe("moduleFromEndpoints", () => { prepareConfig({ method: "GET" }) ); - expect(customHttpClient).toHaveBeenCalledWith(expectedUrl.toString(), { - method: "GET", - params: [], - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - }); + expect(customHttpClient).toHaveBeenCalledWith( + expectedUrl.toString(), + expect.objectContaining({ + method: "GET", + params: [], + }) + ); }); - it("should normalize the base URL", async () => { - const customHttpClient = jest - .fn() - .mockResolvedValue({ id: 1, name: "Test Product" }); + it("should remove trailing slash from the api url", async () => { + const customHttpClient = jest.fn(); const sdkConfig = { commerce: buildModule(moduleFromEndpoints, { apiUrl: "http://localhost:8181/commerce/", @@ -134,21 +123,12 @@ describe("moduleFromEndpoints", () => { expect(customHttpClient).toHaveBeenCalledWith( "http://localhost:8181/commerce/getProduct", - { - method: "POST", - params: [{ id: 1 }], - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - } + expect.any(Object) ); }); it("should allow to use custom headers", async () => { - const customHttpClient = jest - .fn() - .mockResolvedValue({ id: 1, name: "Test Product" }); + const customHttpClient = jest.fn(); const sdkConfig = { commerce: buildModule(moduleFromEndpoints, { apiUrl: "http://localhost:8181/commerce", @@ -169,22 +149,18 @@ describe("moduleFromEndpoints", () => { expect(customHttpClient).toHaveBeenCalledWith( "http://localhost:8181/commerce/getProduct", - { - method: "POST", - params: [{ id: 1 }], + expect.objectContaining({ headers: { "Content-Type": "application/json", Accept: "application/json", "X-Test": "x-test-header", }, - } + }) ); }); it("should allow to define default headers", async () => { - const customHttpClient = jest - .fn() - .mockResolvedValue({ id: 1, name: "Test Product" }); + const customHttpClient = jest.fn(); const sdkConfig = { commerce: buildModule(moduleFromEndpoints, { apiUrl: "http://localhost:8181/commerce", @@ -202,22 +178,18 @@ describe("moduleFromEndpoints", () => { expect(customHttpClient).toHaveBeenCalledWith( "http://localhost:8181/commerce/getProduct", - { - method: "POST", - params: [{ id: 1 }], + expect.objectContaining({ headers: { "Content-Type": "application/json", Accept: "application/json", "X-Test": "x-test-header", }, - } + }) ); }); it("should use different base URL during SSR if defined", async () => { - const customHttpClient = jest - .fn() - .mockResolvedValue({ id: 1, name: "Test Product" }); + const customHttpClient = jest.fn(); const sdkConfig = { commerce: buildModule(moduleFromEndpoints, { apiUrl: "/api/commerce", @@ -231,14 +203,7 @@ describe("moduleFromEndpoints", () => { expect(customHttpClient).toHaveBeenCalledWith( "http://localhost:8181/commerce/getProduct", - { - method: "POST", - params: [{ id: 1 }], - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - } + expect.any(Object) ); }); }); From 51791bd30398684b41eaa80904d6c2c09952f2b9 Mon Sep 17 00:00:00 2001 From: Wojciech Sikora Date: Thu, 29 Feb 2024 16:41:36 +0100 Subject: [PATCH 5/8] fix issue with URL class and urls as paths --- .../modules/moduleFromEndpoints.spec.ts | 29 +++++++++++++++++-- .../utils/getHttpClient.ts | 12 ++++---- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/packages/sdk/src/__tests__/integration/modules/moduleFromEndpoints.spec.ts b/packages/sdk/src/__tests__/integration/modules/moduleFromEndpoints.spec.ts index 2a3f71e982..117c790a2c 100644 --- a/packages/sdk/src/__tests__/integration/modules/moduleFromEndpoints.spec.ts +++ b/packages/sdk/src/__tests__/integration/modules/moduleFromEndpoints.spec.ts @@ -109,11 +109,36 @@ describe("moduleFromEndpoints", () => { ); }); - it("should remove trailing slash from the api url", async () => { + it("should allow to use GET request when apiUrl is a path", async () => { const customHttpClient = jest.fn(); const sdkConfig = { commerce: buildModule(moduleFromEndpoints, { - apiUrl: "http://localhost:8181/commerce/", + apiUrl: "/api/commerce", + httpClient: customHttpClient, + }), + }; + const sdk = initSDK(sdkConfig); + const serializedParams = encodeURIComponent(JSON.stringify([{ limit: 1 }])); + + await sdk.commerce.getProducts( + { limit: 1 }, + prepareConfig({ method: "GET" }) + ); + + expect(customHttpClient).toHaveBeenCalledWith( + `/api/commerce/getProducts?body=${serializedParams}`, + expect.objectContaining({ + method: "GET", + params: [], + }) + ); + }); + + it("should normalize the url", async () => { + const customHttpClient = jest.fn(); + const sdkConfig = { + commerce: buildModule(moduleFromEndpoints, { + apiUrl: "http://localhost:8181/commerce///", // Extra slashes httpClient: customHttpClient, }), }; diff --git a/packages/sdk/src/modules/moduleFromEndpoints/utils/getHttpClient.ts b/packages/sdk/src/modules/moduleFromEndpoints/utils/getHttpClient.ts index 7b2c388ec5..9291cdf757 100644 --- a/packages/sdk/src/modules/moduleFromEndpoints/utils/getHttpClient.ts +++ b/packages/sdk/src/modules/moduleFromEndpoints/utils/getHttpClient.ts @@ -10,7 +10,9 @@ export const getHTTPClient = (options: Options) => { typeof window === "undefined" ? ssrApiUrl || apiUrl : apiUrl; // Ensure the base URL ends with a slash - const normalizedBaseUrl = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`; + // TODO: Update eslint rule to warn on prefer-template instead of error. + // eslint-disable-next-line prefer-template + const normalizedBaseUrl = baseUrl.replace(/\/+$/, "") + "/"; const url = `${normalizedBaseUrl}${path}`; const queryParams = method === "GET" ? params : undefined; @@ -20,11 +22,9 @@ export const getHTTPClient = (options: Options) => { } // If there are query params, append them to the URL as `?body=[]` - const queryParamsString = JSON.stringify(queryParams); - const urlWithParams = new URL(url); - urlWithParams.searchParams.append("body", queryParamsString); + const serializedParams = encodeURIComponent(JSON.stringify(queryParams)); - return urlWithParams.toString(); + return `${url}?body=${serializedParams}`; }; const getConfig = (config: HTTPClientConfig): HTTPClientConfig => { @@ -53,7 +53,7 @@ export const getHTTPClient = (options: Options) => { }; return async (methodName: string, config: HTTPClientConfig) => { - const httpClient = options.httpClient || defaultHTTPClient; + const { httpClient = defaultHTTPClient } = options; return httpClient(getUrl(methodName, config), getConfig(config)); }; From 1e6cdabad34217c78d14d684f3d215088dc73491 Mon Sep 17 00:00:00 2001 From: Wojciech Sikora Date: Fri, 1 Mar 2024 11:03:29 +0100 Subject: [PATCH 6/8] add test for axios --- packages/sdk/package.json | 3 +- .../__tests__/__mocks__/apiClient/server.js | 4 +-- .../modules/moduleFromEndpoints.spec.ts | 34 ++++++++++++++++-- .../src/modules/moduleFromEndpoints/types.ts | 24 +++++-------- .../utils/getHttpClient.ts | 35 +++++++++++++------ yarn.lock | 14 ++++++++ 6 files changed, 81 insertions(+), 33 deletions(-) diff --git a/packages/sdk/package.json b/packages/sdk/package.json index a193d278bd..714e04afdb 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -28,7 +28,8 @@ "ts-jest": "^29.0.2", "ts-node-dev": "^2.0.0", "@vue-storefront/middleware": "*", - "isomorphic-fetch": "^3.0.0" + "isomorphic-fetch": "^3.0.0", + "axios": "^1.6.7" }, "engines": { "npm": ">=7.0.0", diff --git a/packages/sdk/src/__tests__/__mocks__/apiClient/server.js b/packages/sdk/src/__tests__/__mocks__/apiClient/server.js index b7ccd8911d..aae75f0d0a 100644 --- a/packages/sdk/src/__tests__/__mocks__/apiClient/server.js +++ b/packages/sdk/src/__tests__/__mocks__/apiClient/server.js @@ -11,8 +11,8 @@ const onCreate = (settings) => { const { createApiClient } = apiClientFactory({ onCreate, api: { - getProduct: async (_context, _params) => { - return { id: 1, name: "Test Product" }; + getProduct: async (_context, params) => { + return { id: params.id, name: "Test Product" }; }, getProducts: async (_context, _params) => { return [{ id: 1, name: "Test Product" }]; diff --git a/packages/sdk/src/__tests__/integration/modules/moduleFromEndpoints.spec.ts b/packages/sdk/src/__tests__/integration/modules/moduleFromEndpoints.spec.ts index 117c790a2c..fb542ea47c 100644 --- a/packages/sdk/src/__tests__/integration/modules/moduleFromEndpoints.spec.ts +++ b/packages/sdk/src/__tests__/integration/modules/moduleFromEndpoints.spec.ts @@ -6,6 +6,8 @@ import { } from "../../../modules/moduleFromEndpoints"; import { Endpoints } from "../../__mocks__/apiClient/types"; +const axios = require("axios/dist/node/axios.cjs"); + describe("moduleFromEndpoints", () => { it("should be able to be used as standard SDK module", async () => { const sdkConfig = { @@ -82,9 +84,7 @@ describe("moduleFromEndpoints", () => { }); it("should allow to use GET request with query parameters", async () => { - const customHttpClient = jest - .fn() - .mockResolvedValue({ id: 1, name: "Test Product" }); + const customHttpClient = jest.fn(); const sdkConfig = { commerce: buildModule(moduleFromEndpoints, { apiUrl: "http://localhost:8181/commerce", @@ -231,4 +231,32 @@ describe("moduleFromEndpoints", () => { expect.any(Object) ); }); + + it("should be able to use axios as a custom HTTP client", async () => { + expect.assertions(2); + + const sdkConfig = { + commerce: buildModule(moduleFromEndpoints, { + apiUrl: "http://localhost:8181/commerce", + httpClient: async (url, config) => { + const { params, ...restConfig } = config; + const { data } = await axios(url, { + ...restConfig, + data: params, + }); + return data; + }, + }), + }; + const sdk = initSDK(sdkConfig); + + const postResponse = await sdk.commerce.getProduct({ id: 1 }); + const getResponse = await sdk.commerce.getProduct( + { id: 2 }, + prepareConfig({ method: "GET" }) + ); + + expect(postResponse).toEqual({ id: 1, name: "Test Product" }); + expect(getResponse).toEqual({ id: 2, name: "Test Product" }); + }); }); diff --git a/packages/sdk/src/modules/moduleFromEndpoints/types.ts b/packages/sdk/src/modules/moduleFromEndpoints/types.ts index 4515ffc3e7..b842786125 100644 --- a/packages/sdk/src/modules/moduleFromEndpoints/types.ts +++ b/packages/sdk/src/modules/moduleFromEndpoints/types.ts @@ -100,22 +100,14 @@ export type Options = { * * const options: Options = { * apiUrl: "https://api.example.com", - * httpClient: (url, config) => { - * if (config.method === "GET") { - * const queryParams = new URLSearchParams(config.params); - * const urlWithParams = new URL(url); - * urlWithParams.search = queryParams.toString(); - * return axios({ - * ...config, - * url: urlWithParams.toString(), - * }); - * } - * - * return axios({ - * ...config, - * url, - * }); - * }, + * httpClient: async (url, config) => { + const { params, ...restConfig } = config; + const { data } = await axios(url, { + ...restConfig, + data: params, + }); + return data; + }, * }; * ``` */ diff --git a/packages/sdk/src/modules/moduleFromEndpoints/utils/getHttpClient.ts b/packages/sdk/src/modules/moduleFromEndpoints/utils/getHttpClient.ts index 9291cdf757..2e03b26521 100644 --- a/packages/sdk/src/modules/moduleFromEndpoints/utils/getHttpClient.ts +++ b/packages/sdk/src/modules/moduleFromEndpoints/utils/getHttpClient.ts @@ -1,35 +1,36 @@ import { Options, HTTPClientConfig } from "../types"; export const getHTTPClient = (options: Options) => { - const getUrl = (path: string, config: HTTPClientConfig): string => { - const { apiUrl, ssrApiUrl } = options; - const { method = "POST", params = [] } = config; + const { apiUrl, ssrApiUrl, defaultRequestConfig = {} } = options; + const getUrl = ( + path: string, + method: HTTPClientConfig["method"], + params: HTTPClientConfig["params"] + ): string => { // Determine the base URL based on the environment const baseUrl = typeof window === "undefined" ? ssrApiUrl || apiUrl : apiUrl; - // Ensure the base URL ends with a slash + // Ensure the base URL ends with a slash. // TODO: Update eslint rule to warn on prefer-template instead of error. // eslint-disable-next-line prefer-template const normalizedBaseUrl = baseUrl.replace(/\/+$/, "") + "/"; const url = `${normalizedBaseUrl}${path}`; - const queryParams = method === "GET" ? params : undefined; // If there are no query params, return the URL as is - if (!queryParams) { + if (method !== "GET") { return url; } // If there are query params, append them to the URL as `?body=[]` - const serializedParams = encodeURIComponent(JSON.stringify(queryParams)); + const serializedParams = encodeURIComponent(JSON.stringify(params)); return `${url}?body=${serializedParams}`; }; const getConfig = (config: HTTPClientConfig): HTTPClientConfig => { - const { defaultRequestConfig = {} } = options; - const { method = "POST", headers = {}, params = [] } = config; + const { method, headers, params } = config; const defaultHeaders = { "Content-Type": "application/json", Accept: "application/json", @@ -48,13 +49,25 @@ export const getHTTPClient = (options: Options) => { }; const defaultHTTPClient = async (url: string, config: HTTPClientConfig) => { - const response = await fetch(url, config); + const response = await fetch(url, { + ...config, + body: JSON.stringify(config.params), + }); return response.json(); }; return async (methodName: string, config: HTTPClientConfig) => { const { httpClient = defaultHTTPClient } = options; + const { + method = "POST", + headers = {}, + params = [], + ...restConfig + } = config; - return httpClient(getUrl(methodName, config), getConfig(config)); + return httpClient( + getUrl(methodName, method, params), + getConfig({ method, headers, params, ...restConfig }) + ); }; }; diff --git a/yarn.lock b/yarn.lock index f71cac527e..96b8f0c819 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3380,6 +3380,15 @@ axios@^1.0.0: form-data "^4.0.0" proxy-from-env "^1.1.0" +axios@^1.6.7: + version "1.6.7" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.7.tgz#7b48c2e27c96f9c68a2f8f31e2ab19f59b06b0a7" + integrity sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA== + dependencies: + follow-redirects "^1.15.4" + form-data "^4.0.0" + proxy-from-env "^1.1.0" + babel-cli@^6.26.0: version "6.26.0" resolved "https://registry.npmjs.org/babel-cli/-/babel-cli-6.26.0.tgz#502ab54874d7db88ad00b887a06383ce03d002f1" @@ -6352,6 +6361,11 @@ follow-redirects@^1.15.0: resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13" integrity sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA== +follow-redirects@^1.15.4: + version "1.15.5" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.5.tgz#54d4d6d062c0fa7d9d17feb008461550e3ba8020" + integrity sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw== + for-each@^0.3.3: version "0.3.3" resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e" From b9aac2f950cbc531846c5be041a277a05ed2cb6e Mon Sep 17 00:00:00 2001 From: Wojciech Sikora Date: Mon, 4 Mar 2024 11:58:11 +0100 Subject: [PATCH 7/8] add computed headers to http client --- .../modules/moduleFromEndpoints.spec.ts | 66 ++++++++++++++--- .../modules/moduleFromEndpoints/connector.ts | 28 +++----- .../src/modules/moduleFromEndpoints/consts.ts | 2 +- .../src/modules/moduleFromEndpoints/types.ts | 71 +++++++++++-------- .../utils/getHttpClient.ts | 51 ++++++++----- .../utils/prepareConfig.ts | 8 +-- 6 files changed, 146 insertions(+), 80 deletions(-) diff --git a/packages/sdk/src/__tests__/integration/modules/moduleFromEndpoints.spec.ts b/packages/sdk/src/__tests__/integration/modules/moduleFromEndpoints.spec.ts index fb542ea47c..b2276565cf 100644 --- a/packages/sdk/src/__tests__/integration/modules/moduleFromEndpoints.spec.ts +++ b/packages/sdk/src/__tests__/integration/modules/moduleFromEndpoints.spec.ts @@ -63,9 +63,9 @@ describe("moduleFromEndpoints", () => { expect(customHttpClient).toHaveBeenCalledWith( "http://localhost:8181/commerce/getProduct", + [{ id: 1 }], expect.objectContaining({ method: "POST", - params: [{ id: 1 }], }) ); }); @@ -80,6 +80,7 @@ describe("moduleFromEndpoints", () => { const response = await sdk.commerce.getProduct({ id: 1 }); + // To avoid mocking fetch, we're calling the real middleware and verifying the response. expect(response).toEqual({ id: 1, name: "Test Product" }); }); @@ -92,8 +93,6 @@ describe("moduleFromEndpoints", () => { }), }; const sdk = initSDK(sdkConfig); - const expectedUrl = new URL("http://localhost:8181/commerce/getProducts"); - expectedUrl.searchParams.append("body", JSON.stringify([{ limit: 1 }])); await sdk.commerce.getProducts( { limit: 1 }, @@ -101,10 +100,12 @@ describe("moduleFromEndpoints", () => { ); expect(customHttpClient).toHaveBeenCalledWith( - expectedUrl.toString(), + `http://localhost:8181/commerce/getProducts?body=${encodeURIComponent( + JSON.stringify([{ limit: 1 }]) + )}`, + [], expect.objectContaining({ method: "GET", - params: [], }) ); }); @@ -118,7 +119,6 @@ describe("moduleFromEndpoints", () => { }), }; const sdk = initSDK(sdkConfig); - const serializedParams = encodeURIComponent(JSON.stringify([{ limit: 1 }])); await sdk.commerce.getProducts( { limit: 1 }, @@ -126,10 +126,12 @@ describe("moduleFromEndpoints", () => { ); expect(customHttpClient).toHaveBeenCalledWith( - `/api/commerce/getProducts?body=${serializedParams}`, + `/api/commerce/getProducts?body=${encodeURIComponent( + JSON.stringify([{ limit: 1 }]) + )}`, + [], expect.objectContaining({ method: "GET", - params: [], }) ); }); @@ -148,6 +150,7 @@ describe("moduleFromEndpoints", () => { expect(customHttpClient).toHaveBeenCalledWith( "http://localhost:8181/commerce/getProduct", + expect.any(Array), expect.any(Object) ); }); @@ -174,6 +177,7 @@ describe("moduleFromEndpoints", () => { expect(customHttpClient).toHaveBeenCalledWith( "http://localhost:8181/commerce/getProduct", + expect.any(Array), expect.objectContaining({ headers: { "Content-Type": "application/json", @@ -203,6 +207,7 @@ describe("moduleFromEndpoints", () => { expect(customHttpClient).toHaveBeenCalledWith( "http://localhost:8181/commerce/getProduct", + expect.any(Array), expect.objectContaining({ headers: { "Content-Type": "application/json", @@ -228,6 +233,7 @@ describe("moduleFromEndpoints", () => { expect(customHttpClient).toHaveBeenCalledWith( "http://localhost:8181/commerce/getProduct", + expect.any(Array), expect.any(Object) ); }); @@ -238,10 +244,9 @@ describe("moduleFromEndpoints", () => { const sdkConfig = { commerce: buildModule(moduleFromEndpoints, { apiUrl: "http://localhost:8181/commerce", - httpClient: async (url, config) => { - const { params, ...restConfig } = config; + httpClient: async (url, params, config) => { const { data } = await axios(url, { - ...restConfig, + ...config, data: params, }); return data; @@ -259,4 +264,43 @@ describe("moduleFromEndpoints", () => { expect(postResponse).toEqual({ id: 1, name: "Test Product" }); expect(getResponse).toEqual({ id: 2, name: "Test Product" }); }); + + it("should accept headers as Record", async () => { + const customHttpClient = jest.fn(); + const sdkConfig = { + commerce: buildModule(moduleFromEndpoints, { + apiUrl: "http://localhost:8181/commerce", + httpClient: customHttpClient, + defaultRequestConfig: { + headers: { + "X-Test-Default": ["x-test-header", "x-test-header-2"], + }, + }, + }), + }; + const sdk = initSDK(sdkConfig); + + await sdk.commerce.getProduct( + { id: 1 }, + prepareConfig({ + method: "POST", + headers: { + "X-Test": ["x-test-header", "x-test-header-2"], + }, + }) + ); + + expect(customHttpClient).toHaveBeenCalledWith( + "http://localhost:8181/commerce/getProduct", + expect.any(Array), + expect.objectContaining({ + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "X-Test-Default": "x-test-header,x-test-header-2", + "X-Test": "x-test-header,x-test-header-2", + }, + }) + ); + }); }); diff --git a/packages/sdk/src/modules/moduleFromEndpoints/connector.ts b/packages/sdk/src/modules/moduleFromEndpoints/connector.ts index 266faa7245..b588c395d3 100644 --- a/packages/sdk/src/modules/moduleFromEndpoints/connector.ts +++ b/packages/sdk/src/modules/moduleFromEndpoints/connector.ts @@ -1,14 +1,7 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ import { Connector } from "../../types"; import { getHTTPClient } from "./utils/getHttpClient"; -import { - EndpointsConstraint, - Options, - Methods, - RequestConfig, - MethodConfig, -} from "./types"; -import { isRequestConfig } from "./consts"; +import { EndpointsConstraint, Options, Methods, IncomingConfig } from "./types"; +import { isConfig } from "./consts"; export const connector = ( options: Options @@ -22,18 +15,17 @@ export const connector = ( } return async (...params: any[]) => { - let requestConfig: RequestConfig | undefined; - const methodConfig: MethodConfig = params[params.length - 1]; + let config: IncomingConfig | undefined; - if (methodConfig?.[isRequestConfig]) { - const { [isRequestConfig]: omit, ...rest } = params.pop(); - requestConfig = rest; + // If last parameter contains the `isRequestConfig` symbol, it's a request config + if (params.at(-1)?.[isConfig]) { + // Remove the `isRequestConfig` symbol from the request config + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { [isConfig]: omit, ...rest } = params.pop(); + config = rest; } - return httpClient(methodName, { - ...requestConfig, - params, - }); + return httpClient(methodName, params, config); }; }, }) satisfies Connector; diff --git a/packages/sdk/src/modules/moduleFromEndpoints/consts.ts b/packages/sdk/src/modules/moduleFromEndpoints/consts.ts index 788cabe835..311bdf574b 100644 --- a/packages/sdk/src/modules/moduleFromEndpoints/consts.ts +++ b/packages/sdk/src/modules/moduleFromEndpoints/consts.ts @@ -1,4 +1,4 @@ /** * Symbol to mark a method config object. */ -export const isRequestConfig = Symbol("methodConfig"); +export const isConfig = Symbol("config"); diff --git a/packages/sdk/src/modules/moduleFromEndpoints/types.ts b/packages/sdk/src/modules/moduleFromEndpoints/types.ts index b842786125..a1184e3bd6 100644 --- a/packages/sdk/src/modules/moduleFromEndpoints/types.ts +++ b/packages/sdk/src/modules/moduleFromEndpoints/types.ts @@ -1,5 +1,5 @@ import { AnyFunction } from "../../types"; -import { isRequestConfig } from "./consts"; +import { isConfig } from "./consts"; /** * Constraint for the endpoints. @@ -17,38 +17,49 @@ export type EndpointsConstraint = { }; /** - * Config for the request. + * Defines the basic configuration for an HTTP request. + * Specifies the HTTP method to be used. */ -export interface RequestConfig { +export type BaseConfig = { /** - * Headers for the request. + * The HTTP method for the request. Optional. Can be "GET" or "POST". + * @default "POST" */ - headers?: Record; + method?: "GET" | "POST"; +}; + +/** + * User-defined configuration for HTTP requests, extending `BaseConfig`. + * Allows custom headers, supporting both strings and arrays of strings for header values. + */ +export type IncomingConfig = BaseConfig & { /** - * HTTP method for the request. + * Optional custom headers. Keys are header names, values can be a string or an array of strings. */ - method?: "GET" | "POST"; -} + headers?: Record; +}; /** - * Config for the HTTP client. + * Computed configuration for HTTP requests, derived from `IncomingConfig`. + * Normalizes header values to strings for consistent request formatting. */ -export interface HTTPClientConfig extends RequestConfig { +export type ComputedConfig = BaseConfig & { /** - * Parameters for the request. + * Normalized headers for the HTTP request, ensuring all values are strings. */ - params?: any[]; -} + headers?: Record; +}; /** - * Config for the SDK method. + * Configuration specific to a method, merging `IncomingConfig` with an internal flag. + * Indicates that the configuration is ready for making a request. */ -export interface MethodConfig extends RequestConfig { +export type MethodConfig = IncomingConfig & { /** - * It's used to differentiate the method config from the params. + * Internal flag to mark the configuration as specific to a request. */ - [isRequestConfig]: boolean; -} + [isConfig]: boolean; +}; /** * HTTP Client abstraction. @@ -58,10 +69,14 @@ export type HTTPClient = ( * URL for the request. */ url: string, + /** + * Parameters for the request. + */ + params: any[], /** * Config for the request. */ - config: HTTPClientConfig + config?: ComputedConfig ) => Promise; /** @@ -100,14 +115,14 @@ export type Options = { * * const options: Options = { * apiUrl: "https://api.example.com", - * httpClient: async (url, config) => { - const { params, ...restConfig } = config; - const { data } = await axios(url, { - ...restConfig, - data: params, - }); - return data; - }, + * httpClient: async (url, params, config) => { + * const { data } = await axios(url, { + * ...config, + * data: params, + * }); + * + * return data; + * }, * }; * ``` */ @@ -116,7 +131,7 @@ export type Options = { /** * Default request config for each request. */ - defaultRequestConfig?: RequestConfig; + defaultRequestConfig?: IncomingConfig; }; /** diff --git a/packages/sdk/src/modules/moduleFromEndpoints/utils/getHttpClient.ts b/packages/sdk/src/modules/moduleFromEndpoints/utils/getHttpClient.ts index 2e03b26521..debfb065bc 100644 --- a/packages/sdk/src/modules/moduleFromEndpoints/utils/getHttpClient.ts +++ b/packages/sdk/src/modules/moduleFromEndpoints/utils/getHttpClient.ts @@ -1,12 +1,18 @@ -import { Options, HTTPClientConfig } from "../types"; +import { + Options, + IncomingConfig, + BaseConfig, + ComputedConfig, + HTTPClient, +} from "../types"; export const getHTTPClient = (options: Options) => { const { apiUrl, ssrApiUrl, defaultRequestConfig = {} } = options; const getUrl = ( path: string, - method: HTTPClientConfig["method"], - params: HTTPClientConfig["params"] + method: BaseConfig["method"], + params: any[] ): string => { // Determine the base URL based on the environment const baseUrl = @@ -29,45 +35,54 @@ export const getHTTPClient = (options: Options) => { return `${url}?body=${serializedParams}`; }; - const getConfig = (config: HTTPClientConfig): HTTPClientConfig => { - const { method, headers, params } = config; + const getConfig = (config: IncomingConfig): ComputedConfig => { + const { method, headers } = config; const defaultHeaders = { "Content-Type": "application/json", Accept: "application/json", ...defaultRequestConfig.headers, }; + const mergedHeaders = { + ...defaultHeaders, + ...headers, + }; + + const computedHeaders: ComputedConfig["headers"] = {}; + Object.entries(mergedHeaders).forEach(([key, value]) => { + computedHeaders[key] = Array.isArray(value) ? value.join(",") : value; + }); return { ...config, - params: method === "GET" ? [] : params, method, headers: { - ...defaultHeaders, - ...headers, + ...computedHeaders, }, }; }; - const defaultHTTPClient = async (url: string, config: HTTPClientConfig) => { + const defaultHTTPClient: HTTPClient = async ( + url: string, + params: any[], + config?: ComputedConfig + ) => { const response = await fetch(url, { ...config, - body: JSON.stringify(config.params), + body: JSON.stringify(params), }); + return response.json(); }; - return async (methodName: string, config: HTTPClientConfig) => { + return async (methodName: string, params: any[], config?: IncomingConfig) => { const { httpClient = defaultHTTPClient } = options; - const { - method = "POST", - headers = {}, - params = [], - ...restConfig - } = config; + const { method = "POST", headers = {}, ...restConfig } = config ?? {}; + const computedParams = method === "GET" ? [] : params; return httpClient( getUrl(methodName, method, params), - getConfig({ method, headers, params, ...restConfig }) + computedParams, + getConfig({ method, headers, ...restConfig }) ); }; }; diff --git a/packages/sdk/src/modules/moduleFromEndpoints/utils/prepareConfig.ts b/packages/sdk/src/modules/moduleFromEndpoints/utils/prepareConfig.ts index 0b0bc836b5..a666113f63 100644 --- a/packages/sdk/src/modules/moduleFromEndpoints/utils/prepareConfig.ts +++ b/packages/sdk/src/modules/moduleFromEndpoints/utils/prepareConfig.ts @@ -1,5 +1,5 @@ -import { isRequestConfig } from "../consts"; -import { MethodConfig, RequestConfig } from "../types"; +import { isConfig } from "../consts"; +import { IncomingConfig, MethodConfig } from "../types"; /** * Prepare the config for the request. @@ -14,12 +14,12 @@ import { MethodConfig, RequestConfig } from "../types"; * ``` */ export const prepareConfig = < - CustomConfig extends RequestConfig = RequestConfig + CustomConfig extends IncomingConfig = IncomingConfig >( requestConfig: CustomConfig ): MethodConfig => { return { ...requestConfig, - [isRequestConfig]: true, + [isConfig]: true, }; }; From b881f7842760f6503942a16fdf29764a695c6ceb Mon Sep 17 00:00:00 2001 From: Wojciech Sikora Date: Mon, 4 Mar 2024 12:17:10 +0100 Subject: [PATCH 8/8] update tsdocs --- packages/sdk/src/modules/moduleFromEndpoints/types.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/modules/moduleFromEndpoints/types.ts b/packages/sdk/src/modules/moduleFromEndpoints/types.ts index a1184e3bd6..7c0bdbdf2b 100644 --- a/packages/sdk/src/modules/moduleFromEndpoints/types.ts +++ b/packages/sdk/src/modules/moduleFromEndpoints/types.ts @@ -67,10 +67,12 @@ export type MethodConfig = IncomingConfig & { export type HTTPClient = ( /** * URL for the request. + * @remarks + * It's the full URL for the request, including the base URL, endpoint and query parameters. */ url: string, /** - * Parameters for the request. + * Parameters for the POST request. */ params: any[], /**