diff --git a/.changeset/brave-mirrors-design.md b/.changeset/brave-mirrors-design.md new file mode 100644 index 0000000000..389d953028 --- /dev/null +++ b/.changeset/brave-mirrors-design.md @@ -0,0 +1,5 @@ +--- +"@vue-storefront/sdk": patch +--- + +[CHANGED] SDK extension allows now to override module methods in `extend` property. diff --git a/packages/sdk/src/__tests__/integration/modules/moduleFromEndpoints.spec.ts b/packages/sdk/src/__tests__/integration/modules/moduleFromEndpoints.spec.ts index 848016b28d..9e61ada292 100644 --- a/packages/sdk/src/__tests__/integration/modules/moduleFromEndpoints.spec.ts +++ b/packages/sdk/src/__tests__/integration/modules/moduleFromEndpoints.spec.ts @@ -370,4 +370,124 @@ describe("moduleFromEndpoints", () => { expect(res).toEqual({ id: 1, name: "Test Category" }); }); + + it("should allow to add new methods with a standard extension", async () => { + const sdk = initSDK({ + commerce: buildModule( + moduleFromEndpoints, + { + apiUrl: "http://localhost:8181/commerce", + }, + { + extend: { + customMethod: async (params: { id: number }) => { + return { id: params.id, name: "Custom method" }; + }, + }, + } + ), + }); + + const res = await sdk.commerce.customMethod({ id: 1 }); + + expect(res).toEqual({ id: 1, name: "Custom method" }); + }); + + it("should allow to reuse the request sender in extensions", async () => { + const customHttpClient = jest + .fn() + .mockResolvedValue({ id: 1, name: "Custom method" }); + const sdk = initSDK({ + commerce: buildModule( + moduleFromEndpoints, + { + apiUrl: "http://localhost:8181/commerce", + httpClient: customHttpClient, + }, + (_, { context }) => ({ + extend: { + customMethod: async (params: { id: number }) => { + return context.requestSender("customMethod", [params]); + }, + }, + }) + ), + }); + + await sdk.commerce.customMethod({ id: 1 }); + + expect(customHttpClient).toHaveBeenCalledWith( + "http://localhost:8181/commerce/customMethod", + [{ id: 1 }], + expect.objectContaining({ + method: "POST", + }) + ); + }); + + it("should allow to use custom error handler in extensions", async () => { + const error = new Error("Test error"); + const customErrorHandler = jest + .fn() + .mockResolvedValue({ id: 1, name: "Error handler did a good job" }); + const customHttpClient = jest.fn().mockRejectedValue(error); + const sdk = initSDK({ + commerce: buildModule( + moduleFromEndpoints, + { + apiUrl: "http://localhost:8181/commerce", + httpClient: customHttpClient, + errorHandler: customErrorHandler, + }, + (_, { context }) => ({ + extend: { + /** + * Custom method. + * TSDoc to test if it's visible. + */ + customMethod: async (params: { id: number }) => { + return context.requestSender("customMethod", [params]); + }, + }, + }) + ), + }); + + const res = await sdk.commerce.customMethod({ id: 1 }); + expect(customErrorHandler).toHaveBeenCalledWith({ + error, + methodName: "customMethod", + params: [{ id: 1 }], + url: "http://localhost:8181/commerce/customMethod", + config: expect.any(Object), + httpClient: customHttpClient, + }); + expect(res).toEqual({ id: 1, name: "Error handler did a good job" }); + }); + + it("should allow to override SDK methods in extensions", async () => { + const sdk = initSDK({ + commerce: buildModule( + moduleFromEndpoints, + { + apiUrl: "http://localhost:8181/commerce", + }, + { + override: { + /** + * Get the product by id. + * TSDoc to test if it's also overridden. + */ + getProduct: async (params: { id: number }) => { + return { id: params.id, name: "Custom method" }; + }, + }, + } + ), + }); + + const res = await sdk.commerce.getProduct({ id: 1 }); + + expect(res).toEqual({ id: 1, name: "Custom method" }); + }); }); diff --git a/packages/sdk/src/bootstrap.ts b/packages/sdk/src/bootstrap.ts index b1957d89e7..d972ff233c 100644 --- a/packages/sdk/src/bootstrap.ts +++ b/packages/sdk/src/bootstrap.ts @@ -65,7 +65,8 @@ export const initSDK = (sdkConfig: T): SDKApi => { const methodFromExtend = Reflect.get(extend, propKey, receiver); const methodFromTarget = Reflect.get(target, propKey, receiver); - const method = methodFromTarget ?? methodFromExtend; + const method = methodFromExtend ?? methodFromTarget; + if (!method) return method; const wrappedMethod = interceptorsManager.applyInterceptors( diff --git a/packages/sdk/src/modules/moduleFromEndpoints/connector.ts b/packages/sdk/src/modules/moduleFromEndpoints/connector.ts index 0aac259cc6..6c91b4af61 100644 --- a/packages/sdk/src/modules/moduleFromEndpoints/connector.ts +++ b/packages/sdk/src/modules/moduleFromEndpoints/connector.ts @@ -1,6 +1,10 @@ import { Connector } from "../../types"; -import { getHTTPClient } from "./utils/getHttpClient"; -import { EndpointsConstraint, Options, Methods, IncomingConfig } from "./types"; +import { + EndpointsConstraint, + Methods, + RequestConfig, + RequestSender, +} from "./types"; import { isConfig } from "./consts"; /** @@ -9,9 +13,8 @@ import { isConfig } from "./consts"; * Implements the Proxy pattern. */ export const connector = ( - options: Options + requestSender: RequestSender ) => { - const httpClient = getHTTPClient(options); const target = {} as Methods; return new Proxy>(target, { get: (_, methodName) => { @@ -20,7 +23,7 @@ export const connector = ( } return async (...params: any[]) => { - let config: IncomingConfig | undefined; + let config: RequestConfig | undefined; const lastParam = params.at(-1); // If last parameter contains the `isRequestConfig` symbol, it's a request config @@ -31,7 +34,7 @@ export const connector = ( config = rest; } - return httpClient(methodName, params, config); + return requestSender(methodName, params, config); }; }, }) satisfies Connector; diff --git a/packages/sdk/src/modules/moduleFromEndpoints/index.ts b/packages/sdk/src/modules/moduleFromEndpoints/index.ts index 7c3958f882..bdf750980d 100644 --- a/packages/sdk/src/modules/moduleFromEndpoints/index.ts +++ b/packages/sdk/src/modules/moduleFromEndpoints/index.ts @@ -1,3 +1,3 @@ export { moduleFromEndpoints } from "./module"; -export { getHTTPClient, prepareConfig } from "./utils"; +export { getRequestSender, prepareConfig } from "./utils"; export * from "./types"; diff --git a/packages/sdk/src/modules/moduleFromEndpoints/module.ts b/packages/sdk/src/modules/moduleFromEndpoints/module.ts index b049cf6bfd..0521bf4f29 100644 --- a/packages/sdk/src/modules/moduleFromEndpoints/module.ts +++ b/packages/sdk/src/modules/moduleFromEndpoints/module.ts @@ -1,9 +1,10 @@ import { Module } from "../../types"; import { connector } from "./connector"; import { EndpointsConstraint, Options } from "./types"; +import { getRequestSender } from "./utils"; /** - * `moduleFromEndpoints` module is allowing to communicate with the Server Middleware API. + * `moduleFromEndpoints` is allowing to communicate with the Server Middleware API. * * It generates the methods to communicate with the API based on the provided endpoints interface. * @@ -20,7 +21,7 @@ import { EndpointsConstraint, Options } from "./types"; * })); * ``` * - * It also exposes the `context` with the `httpClient` to allow to use the `httpClient` directly in extensions. + * It also exposes the `context` with the `requestSender` to allow to use it directly in extensions. * * @example * Usage: @@ -31,7 +32,7 @@ import { EndpointsConstraint, Options } from "./types"; * const extension = (extensionOptions, { methods, context }) => ({ * extend: { * async newMethod(params) { - * const response = await context.httpClient("http://localhost:4000/sapcc/extended-endpoint", { params }); + * const response = await context.requestSender("http://localhost:4000/sapcc/extended-endpoint", { params }); * const responseJson = await response.json(); * const products = await methods.getProducts(params); * return { ...responseJson, ...products }; @@ -48,7 +49,13 @@ import { EndpointsConstraint, Options } from "./types"; */ export const moduleFromEndpoints = ( options: Options -) => - ({ - connector: connector(options), - } satisfies Module); +) => { + const requestSender = getRequestSender(options); + + return { + connector: connector(requestSender), + context: { + requestSender, + }, + } satisfies Module; +}; diff --git a/packages/sdk/src/modules/moduleFromEndpoints/types.ts b/packages/sdk/src/modules/moduleFromEndpoints/types.ts index 58a96c7b55..70435289d5 100644 --- a/packages/sdk/src/modules/moduleFromEndpoints/types.ts +++ b/packages/sdk/src/modules/moduleFromEndpoints/types.ts @@ -2,14 +2,15 @@ import { AnyFunction } from "../../types"; import { isConfig } from "./consts"; /** - * Defines a constraint for API endpoint functions. - * Each endpoint function should return a Promise. + * Represents the constraint for API endpoint functions within the SDK. + * Each endpoint function must return a Promise, allowing for asynchronous operations. * * @example - * ```ts + * ```typescript + * // Definition of an API endpoint structure * type Endpoints = { - * getUser: (id: string) => Promise; - * createUser: (data: CreateUser) => Promise; + * getUser: ({ id: string }) => Promise; + * createUser: (userDetails: CreateUserDetails) => Promise; * }; * ``` */ @@ -18,8 +19,11 @@ export type EndpointsConstraint = { }; /** - * Defines the basic configuration for an HTTP request. - * Specifies the HTTP method to be used. + * Base configuration object for HTTP requests. It includes essential configurations like the HTTP method. + * + * @remarks + * This type serves as a base for more detailed configuration objects by specifying + * the method of the HTTP request. */ export type BaseConfig = { /** @@ -33,7 +37,7 @@ export type BaseConfig = { * 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 & { +export type RequestConfig = BaseConfig & { /** * Optional custom headers. Keys are header names, values can be a string or an array of strings. */ @@ -41,7 +45,7 @@ export type IncomingConfig = BaseConfig & { }; /** - * Computed configuration for HTTP requests, derived from `IncomingConfig`. + * Computed configuration for HTTP requests, derived from `RequestConfig`. * Normalizes header values to strings for consistent request formatting. */ export type ComputedConfig = BaseConfig & { @@ -52,10 +56,10 @@ export type ComputedConfig = BaseConfig & { }; /** - * Configuration specific to a method, merging `IncomingConfig` with an internal flag. + * Configuration specific to a method, merging `RequestConfig` with an internal flag. * Indicates that the configuration is ready for making a request. */ -export type MethodConfig = IncomingConfig & { +export type MethodConfig = RequestConfig & { /** * Internal flag to mark the configuration as specific to a request. */ @@ -63,21 +67,49 @@ export type MethodConfig = IncomingConfig & { }; /** - * HTTP Client abstraction. + * Represents a function type for sending HTTP requests, abstracting the complexity of request configuration. + * + * @remarks + * This type is created via a factory function that configures it with common settings, such as base URLs and default headers. + * + * It simplifies making HTTP requests by handling URL construction, parameter serialization, and applying default and overridden configurations. + */ +export type RequestSender = ( + /** + * Name of the SDK method that was called to make the HTTP request. + */ + methodName: string, + + /** + * The parameters of the method that was called to make the HTTP request. + */ + params: unknown[], + + /** + * User-defined configuration for the HTTP request. + */ + config?: RequestConfig +) => Promise; + +/** + * A customizable HTTP client function for making HTTP requests. + * + * @remarks This type represents a flexible interface for HTTP clients within the SDK, allowing for + * customization and substitution of different HTTP request mechanisms (e.g., Fetch API, Axios). */ export type HTTPClient = ( /** - * URL for the request. - * @remarks - * It's the full URL for the request, including the base URL, endpoint and query parameters. + * The URL for the HTTP request. */ url: string, + /** - * Parameters for the POST request. + * The parameters for the POST HTTP request. */ params: unknown[], + /** - * Config for the request. + * The computed configuration for the HTTP request, after processing url, query params and headers. */ config?: ComputedConfig ) => Promise; @@ -177,7 +209,7 @@ export type Options = { /** * Default request config for each request. */ - defaultRequestConfig?: IncomingConfig; + defaultRequestConfig?: RequestConfig; /** * An optional custom error handler for HTTP requests. diff --git a/packages/sdk/src/modules/moduleFromEndpoints/utils/getHttpClient.ts b/packages/sdk/src/modules/moduleFromEndpoints/utils/getRequestSender.ts similarity index 83% rename from packages/sdk/src/modules/moduleFromEndpoints/utils/getHttpClient.ts rename to packages/sdk/src/modules/moduleFromEndpoints/utils/getRequestSender.ts index 073d68437a..c13fd6c38b 100644 --- a/packages/sdk/src/modules/moduleFromEndpoints/utils/getHttpClient.ts +++ b/packages/sdk/src/modules/moduleFromEndpoints/utils/getRequestSender.ts @@ -1,13 +1,21 @@ import { Options, - IncomingConfig, + RequestConfig, BaseConfig, ComputedConfig, HTTPClient, ErrorHandler, + RequestSender, } from "../types"; -export const getHTTPClient = (options: Options) => { +/** + * Generates a `RequestSender` function configured according to the provided options. + * + * @remarks + * This function abstracts away the details of constructing request URLs, merging configurations, + * handling errors, and executing HTTP requests. + */ +export const getRequestSender = (options: Options): RequestSender => { const { apiUrl, ssrApiUrl, defaultRequestConfig = {} } = options; const getUrl = ( @@ -36,7 +44,7 @@ export const getHTTPClient = (options: Options) => { return `${url}?body=${serializedParams}`; }; - const getConfig = (config: IncomingConfig): ComputedConfig => { + const getConfig = (config: RequestConfig): ComputedConfig => { const { method, headers } = config; const defaultHeaders = { "Content-Type": "application/json", @@ -70,6 +78,7 @@ export const getHTTPClient = (options: Options) => { const response = await fetch(url, { ...config, body: JSON.stringify(params), + credentials: "include", }); return response.json(); @@ -79,11 +88,7 @@ export const getHTTPClient = (options: Options) => { throw error; }; - return async ( - methodName: string, - params: unknown[], - config?: IncomingConfig - ) => { + return async (methodName, params, config?) => { const { httpClient = defaultHTTPClient, errorHandler = defaultErrorHandler, diff --git a/packages/sdk/src/modules/moduleFromEndpoints/utils/index.ts b/packages/sdk/src/modules/moduleFromEndpoints/utils/index.ts index 3527cb37fe..ba33ce38b9 100644 --- a/packages/sdk/src/modules/moduleFromEndpoints/utils/index.ts +++ b/packages/sdk/src/modules/moduleFromEndpoints/utils/index.ts @@ -1,2 +1,2 @@ -export { getHTTPClient } from "./getHttpClient"; +export { getRequestSender } from "./getRequestSender"; export { prepareConfig } from "./prepareConfig"; diff --git a/packages/sdk/src/modules/moduleFromEndpoints/utils/prepareConfig.ts b/packages/sdk/src/modules/moduleFromEndpoints/utils/prepareConfig.ts index a666113f63..f20d5d9dd1 100644 --- a/packages/sdk/src/modules/moduleFromEndpoints/utils/prepareConfig.ts +++ b/packages/sdk/src/modules/moduleFromEndpoints/utils/prepareConfig.ts @@ -1,5 +1,5 @@ import { isConfig } from "../consts"; -import { IncomingConfig, MethodConfig } from "../types"; +import { RequestConfig, MethodConfig } from "../types"; /** * Prepare the config for the request. @@ -14,7 +14,7 @@ import { IncomingConfig, MethodConfig } from "../types"; * ``` */ export const prepareConfig = < - CustomConfig extends IncomingConfig = IncomingConfig + CustomConfig extends RequestConfig = RequestConfig >( requestConfig: CustomConfig ): MethodConfig => { diff --git a/packages/sdk/src/types/index.ts b/packages/sdk/src/types/index.ts index b56f3b15fc..d5b38b95ad 100644 --- a/packages/sdk/src/types/index.ts +++ b/packages/sdk/src/types/index.ts @@ -189,7 +189,6 @@ export type Extension = Omit< /** * Extend contains methods that are added to the module. * Because of the dynamic nature of the SDK, the extend method must be an asynchronous function. - * Extending methods can't be used to override the connector. * * @example * Extending the module with a new method. @@ -287,16 +286,18 @@ export type ExtensionInitializer< * The following type map understand the SDK configuration input and produce * usable SDK api with all type hints. */ + export type SDKApi = { - [ExtensionName in keyof Config]: { - +readonly [Method in - | keyof Config[ExtensionName]["connector"] - | keyof Config[ExtensionName]["override"]]: Method extends keyof Config[ExtensionName]["override"] - ? Config[ExtensionName]["override"][Method] - : Config[ExtensionName]["connector"][Method]; - } & { - +readonly [Method in keyof Config[ExtensionName]["extend"]]: Config[ExtensionName]["extend"][Method]; - }; + [ExtensionName in keyof Config]: Config[ExtensionName]["extend"] & + Omit< + Config[ExtensionName]["override"], + keyof Config[ExtensionName]["extend"] + > & + Omit< + Config[ExtensionName]["connector"], + keyof Config[ExtensionName]["override"] & + keyof Config[ExtensionName]["extend"] + >; } & { +readonly [ExtensionName in keyof Config]: { utils: {