Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/brave-mirrors-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@vue-storefront/sdk": patch
---

[CHANGED] SDK extension allows now to override module methods in `extend` property.
Original file line number Diff line number Diff line change
Expand Up @@ -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<Endpoints>,
{
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<Endpoints>,
{
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<Endpoints>,
{
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<Endpoints>,
{
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" });
});
});
3 changes: 2 additions & 1 deletion packages/sdk/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ export const initSDK = <T extends SDKConfig>(sdkConfig: T): SDKApi<T> => {
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(
Expand Down
15 changes: 9 additions & 6 deletions packages/sdk/src/modules/moduleFromEndpoints/connector.ts
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand All @@ -9,9 +13,8 @@ import { isConfig } from "./consts";
* Implements the Proxy pattern.
*/
export const connector = <Endpoints extends EndpointsConstraint>(
options: Options
requestSender: RequestSender
) => {
const httpClient = getHTTPClient(options);
const target = {} as Methods<Endpoints>;
return new Proxy<Methods<Endpoints>>(target, {
get: (_, methodName) => {
Expand All @@ -20,7 +23,7 @@ export const connector = <Endpoints extends EndpointsConstraint>(
}

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
Expand All @@ -31,7 +34,7 @@ export const connector = <Endpoints extends EndpointsConstraint>(
config = rest;
}

return httpClient(methodName, params, config);
return requestSender(methodName, params, config);
};
},
}) satisfies Connector;
Expand Down
2 changes: 1 addition & 1 deletion packages/sdk/src/modules/moduleFromEndpoints/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export { moduleFromEndpoints } from "./module";
export { getHTTPClient, prepareConfig } from "./utils";
export { getRequestSender, prepareConfig } from "./utils";
export * from "./types";
21 changes: 14 additions & 7 deletions packages/sdk/src/modules/moduleFromEndpoints/module.ts
Original file line number Diff line number Diff line change
@@ -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.
*
Expand All @@ -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:
Expand All @@ -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 };
Expand All @@ -48,7 +49,13 @@ import { EndpointsConstraint, Options } from "./types";
*/
export const moduleFromEndpoints = <Endpoints extends EndpointsConstraint>(
options: Options
) =>
({
connector: connector<Endpoints>(options),
} satisfies Module);
) => {
const requestSender = getRequestSender(options);

return {
connector: connector<Endpoints>(requestSender),
context: {
requestSender,
},
} satisfies Module;
};
68 changes: 50 additions & 18 deletions packages/sdk/src/modules/moduleFromEndpoints/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<User>;
* createUser: (data: CreateUser) => Promise<User>;
* getUser: ({ id: string }) => Promise<User>;
* createUser: (userDetails: CreateUserDetails) => Promise<User>;
* };
* ```
*/
Expand All @@ -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 = {
/**
Expand All @@ -33,15 +37,15 @@ 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.
*/
headers?: Record<string, string | string[]>;
};

/**
* 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 & {
Expand All @@ -52,32 +56,60 @@ 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.
*/
[isConfig]: boolean;
};

/**
* 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<any>;

/**
* 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<any>;
Expand Down Expand Up @@ -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.
Expand Down
Loading