Generated API
Overview
Alokai takes advantage of OpenAPI to create a TypeScript API client for SAP Commerce Cloud. This client helps us quickly set up Express endpoints for the Alokai Storefront. By using the generated API, we guarantee complete coverage and make it easy to update as needed.
How it works
To generate the API client, we use the OpenAPI Generator CLI. This tool allows us to generate API clients for any API that has an OpenAPI specification. The process is as follows:
- The SDK class is generated based on the OpenAPI specification -
https://api.c1jvi8hu9a-vsfspzooa1-d1-public.model-t.cc.commerce.ondemand.com/occ/v2/api-docs
. It can be an URL of your SAP Commerce Cloud API. -g
flag specifies the generator to use. In our case, we usetypescript-axios
to generate a TypeScript client. In our case, it is best to use the Axios client because our unified layer expects data structures in a specific format. Of course, you can use any other generator that suits your needs and adjust the unified layer to work with it.-c
flag specifies the configuration file. We useconfig.yaml
to set up the generator. The configuration file is used to set up the generator and adjust the output to our needs. In the configuration file, we set up theopenapi-normalizer
to set tags for all operations tosapcc
. This way, we can easily filter out the operations that we want to include in our API client. The example configuration file can be found in the root of the project.
additionalProperties:
useSingleRequestParameter: true
supportsES6: true
-o
flag specifies the output directory. In our case, we usesrc
to generate the API client in thesrc
directory.--skip-validate-spec
flag skips the validation of the OpenAPI specification. This is useful when the specification is not fully compliant with the OpenAPI standard.--openapi-normalizer SET_TAGS_FOR_ALL_OPERATIONS=sapcc
flag sets the tags for all operations tosapcc
. This way, we can easily filter out the operations that we want to include in our API client.This step is crucial if you want to seamlessly replace our client with your own. Building API we expect to have a single class with methods representing all endpoints. It is possible to generate it in a different ay but you will not be able to use it with our generator.
We generated the SAPCC Webservices using command below:
npx @openapitools/openapi-generator-cli generate -i https://api.c1jvi8hu9a-vsfspzooa1-d1-public.model-t.cc.commerce.ondemand.com/occ/v2/api-docs -g typescript-axios -c config.yaml -o src --skip-validate-spec --openapi-normalizer SET_TAGS_FOR_ALL_OPERATIONS=sapcc
As a result we get a class that contains all methods that matches the endpoints in the OpenAPI schema. The class is generated in the configured repository. Once we get that class we wrap it in a proxy class that adds some additional features like logging and error handling. The proxy class is then used in the middleware configuration to build and expose the final Alokai API endpoints.
Additional features
We preserve the interfaces of methods that are exposed as API endpoints which means that you should expect exactly the same input and output as described in the OpenAPI schema (Swagger) however we add some additional features that works on top of the generated API client. We created a proxy builder function that takes the generated SDK class and adds some additional features to it. The features that we add are:
- logger - we add a logger to the client that logs all errors. This way, we can easily debug the client and see what is going on under the hood. You can read more about the logger in the Logger Docs section.
- injection of common parameters - some parameters repeats in all endpoints makes it cumbersome to add them to each request. Example of such parameters are
baseSiteId
,userId
orfields
. Even though these parameters are required in the OpenAPI schema, we mark them as optional and inject them into each function call.
How to use it
Once the API client is generated, you can use it in the middleware configuration to build and expose the final integration endpoints. For example, you can use the following code to create a proxy class for the SAP Commerce Assisted Service Module:
// for version ^8.0.0
import { createProxyApi } from "@vsf-enterprise/sapcc-api";
export const asmExtension: ApiClientExtension = {
name: "asm",
isNamespaced: true,
extendApiMethods(context) {
return {
...createProxyApi(SapccAsmApi, context.asmClient),
}
},
};
The createProxyApi
is our utility function that takes the generated SDK class and adds some additional features to it but you can also write your own function that will fit your needs. The main goal is to have a function that will return an object with enumerable properties that are functions that will be exposed as API endpoints.
A second parameter is optional, it's used to provide a reference to Axios' instance, we want to use for sending requests.
Code example of createProxyApi
function
export const createProxyApi = <T extends object>(
OpenApiClass: {
new (...params: ConstructorParameters<typeof SapccApi>): T;
},
axiosInstance?: AxiosInstance
) => {
/*
* For more info about JavaScript Proxies, see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/Proxy
*
* This Proxy is meant to be a "behaves-like-an-object" adapter for the *CLASS* `new SapccApi()`
* `apiClientFactory` at the bottom of this file wasn't made to accept classes in the `{ api: }` param (`{ api: new SapccApi() }`)
* So we need to wrap the SapccApi class with an object, which this proxy does.
*
*/
return new Proxy(new OpenApiClass(undefined, undefined, axiosInstance), {
/*
* This method describes what happens when a function tries to list the keys of our proxy object. e.g. `Object.keys(proxy)`
* In the case of this implementation, it will list all the methods of the `SapccApi` class instance. e.g. `Object.keys(proxy) // ['createCart', 'getProduct', ...]
* @example `Object.keys(proxy) // lists all methods in `new SapccApi()`
*
* `ownKeys` is required because otherwise `Object.entries` call in the below link in the middleware package always returns an empty array (see @example below)
* @see {@link https://github.com/vuestorefront/vue-storefront/blob/main/packages/middleware/src/apiClientFactory/applyContextToApi.ts#L33}
* @example `Object.entries(new SapccApi()) // returns []
* This is because Object.keys only lists *own* *properties* of an *object*, but we passed a *class* which doesn't have *own* properties - it has only *inherited* properties in __proto__.
*/
ownKeys(target) {
/*
* "Damn why didn't he use `Object.keys` to list class methods? Would be easier?"
* Object.keys(new SapccApi()) -> [], wrong
* Object.getOwnPropertyNames(new SapccApi()) -> [], wrong
* Object.keys((new SapccApi()).__proto__) -> [], wrong
* Object.getOwnPropertyNames((new SapccApi()).__proto__) -> actually gets class methods
*/
return Object.getOwnPropertyNames(
// @ts-expect-error Hidden property
// eslint-disable-next-line no-proto
target.__proto__
);
},
// https://stackoverflow.com/questions/65339985/why-isnt-ownkeys-proxy-trap-working-with-object-keys
getOwnPropertyDescriptor(_target, property) {
return {
// we do not want to iterate over the constructor
enumerable: property !== "constructor",
configurable: true,
};
},
/*
* This is the function that defines what happens
* when the expression `proxy['prop']` is evaluated.
*
* Normally we'd do just write `return (new SapccApi())[prop]`
*
* All the quirks are described in the comments in the implementation below.
*
* @see {@link https://github.com/vuestorefront/vue-storefront/blob/main/packages/middleware/src/apiClientFactory/applyContextToApi.ts#L40}
*/
get(openApiClientInstance, methodName: string) {
if (methodName === "then") {
return Promise.resolve(openApiClientInstance[methodName]);
}
if (!(methodName in openApiClientInstance)) {
throw new Error(
`The API client tried to find a method named ${`methodName`} but no such method exists within the integration`
);
}
/*
* Normally we'd just `return target[prop]`,
* but we need to wrap that in a function that reorders the arguments.
* `@vue-storefront/middleware` expects to receive methods in the format:
* `someApiMethod(middlewareContext, methodProps)`
* (see {@link https://github.com/vuestorefront/vue-storefront/blob/main/packages/middleware/src/apiClientFactory/applyContextToApi.ts#L40}):
* but all of `new SapccApi` methods expect the format:
* `someApiMethod(methodProps, options?)`
* So in real life like:
* `createCart({ baseSiteId: 'apparel-uk'}, { headers: {...}})`
*
* This also gives you access to the context, so you can grab the `apiUrl` from the middleware configuration and pass it to the method. By default it just queries our SAP CC instance.
*/
return async (
context: Context["$sapcc"],
// most of the time this is actually just [object], not object[] so you can do args[0]
...args: object[]
) => {
/* Calling e.g. proxy.createCart() without the .bind will result in `this.configuration is undefined` errors
* this meant that the function's lexical context "forgot" to which class it belongs,
* so we use .bind() to reconnect it to the SapccApi class instance.
*/
const apiFunction = openApiClientInstance[methodName].bind(
openApiClientInstance
) as GenericOpenapiMethod;
const { logger } = context.config;
/*
* The regular props that the method accepts in query or body (baseSiteId, userId, and others, depending on what the method is, like creatCart has different args etc)
* Always append baseSiteId from the config - we don't let the user provide it on a per-method basis
* The BaseProps type is included in the union because of Alokai SDK Methods
*/
const props: Record<string, unknown> & BaseProps = {
baseSiteId: context.config.api.baseSiteId, // this is required for SapccAPI
baseSite: context.config.api.baseSiteId, // this is required for SapccAsmAPI
userId: getUserIdFromRequest({ req: context.req, props: args[0] }),
fields: "FULL",
...args[0],
};
const axiosRequestOptions = createRequestOptionsWithJsonHeader({
context,
props,
tokenMode: getTokenModeByMethodName(methodName),
});
try {
const response = await apiFunction(props, axiosRequestOptions);
/**
* We have to remove the request object because of the circular reference error.:
* Starting Object: The circular structure starts with an object constructed using the ClientRequest constructor.
* Circular Path:
* This ClientRequest object has a property named socket which is an instance of TLSSocket.
* The TLSSocket instance has a property _httpMessage that references back to the ClientRequest object.
* Cycle: This creates a circular reference where ClientRequest references TLSSocket, which in turn references back to ClientRequest.
*/
response.request = null;
return response;
} catch (error) {
if (logger) {
logger.error(`api/${methodName}`, error.message);
}
throw error;
}
};
},
});
};