In the context of our project, a connector is a package that implements a specific interface, the Connector Contract. This contract is a standardized API that outlines shared capabilities among connectors, such as searching/filtering products, managing carts, wishlists, and more. The Connector Contract also defines standardized data types like Product
or Cart
, as well as helper types like Result
.
Creating a connector package is the first step in developing a connector. This process involves several steps:
Navigate to the appropriate directory in the monorepo (packages/connectors
for connectors). Create a new directory under this directory. The name of the directory should be the name of your new package.
Inside this new directory, initialize a new package with pnpm init -y
. This will create a package.json
file with default values. You can then edit this file to specify the details of your package.
Install the necessary dependencies. At a minimum, you'll need the @etribes/connector-contracts
package, which contains the connector contract that your connector must satisfy.
Set up the necessary scripts in your package.json
file. At a minimum, you should have a test
script that runs your test suite and a coverage
script that runs your test suite and outputs coverage information.
Once you've established your connector package, you can start writing your connector. The package will house all the code related to your connector.
The Connector Contract is a TypeScript interface that outlines the methods each connector must implement. While a connector doesn't directly implement the Connector Contract interface, it "satisfies" it. This approach allows a connector to offer additional features beyond those defined in the contract. However, while extending functionality can be beneficial, it's important to exercise caution. Features that are not covered by the contract could potentially cause issues when the connector is swapped out. Therefore, it's crucial to ensure that any additional features are implemented in a way that maintains compatibility and interoperability with the Connector Contract.
The connector contract includes methods for handling sessions, customers, products, and carts. It also defines several data types, such as Product
, Variant
, Price
, Image
, and Cart
, as well as helper types like Result
.
Each operation in the contract, and subsequently in the connector, must return either a Promise of a Success type (Ok<T>
) or a Promise of an Error Type (Err<E>
). This means that your connector should never throw any errors. Instead, the connector should intercept any occurring errors and return an Err
of either type ApiError
or ValidationErrorType
. The latter is used for informing the shops that a specific validation has gone wrong.
In the development process, connectors are typically not written in one go. It's common to implement and validate individual functions one at a time. TypeScript's satisfies
operator allows you to validate parts of the implementation, ensuring that each function adheres to the Connector Contract. Here's an example of how you can validate the getById
function in the products
object:
products: {
getById: (async (handle: string) => {
return err({ type: 'NOT_FOUND_ERROR' as const });
}) satisfies Connector['products']['getById'],
}
In this example, the getById
function is validated against the corresponding function in the Connector Contract. This approach allows you to incrementally build and validate your connector, ensuring that each function correctly implements the required functionality and adheres to the Connector Contract.
However, adhering to the contract is not the only requirement. Different connector implementations should be easy to swap out. Therefore, it is crucial that not only the types are correct, but also the contents and behavior are similar across different implementations. This consistency ensures that switching between different connectors does not introduce unexpected behavior or break existing functionality.
We assume that our Shop has Products that are a container for multiple Variants (if your shop system allows products with no variants, you'll have to create one on the connector)
Attributes are descriptive "Flags" that can be attach to either Products or Variants. So eg. Color Blue might be a Product Attribute (because all Variants are of the same color) while Size XS might be a Variant Attribute.
Every Attribute belongs to an AttributeGroup, that's the Color or Size above.
Variant Attributes have an additional Field called distinctive. If it is set to true then the following logic kicks in:
Assume we have 2 VariantAttributes Size and Length, both flagged as distinctive. The frontend needs to render them in a way that the user has to chose an entry for both of them (Size 32, Length 34) in order to select a Variant to add to the cart.
Prices and Stock Quantities are also attached on the Variant level.
Images can be attached on the Variant level, but mostly they should be at Product level (only if an Image is specific to a Variant)
Products hold a number of fields like brand, categories or images, these are all shared between the Variants of it. There is also an aggregate Field called priceRange which combines the min/max price of all Variants
When doing a search or category load, you will get back a list of products. An important concept are Facets: These are aggregates per AttributeGroup. So eg. for AttributeGroup Color you'd get the number of red, green or blue products in your result set.
Typically the shop can tell the search endpoint which groups should be use as Facets.
There are two types of Facets, List Facets (red, green, blue) or Range Facets that express eg. price ranges.
Typically these Facets are used as Filters to drill down into the search results. Based on the Facets there are RangeFilters, SingleValueFilters and MultiValueFilters. MultiValueFilters are OR based within an AttributeGroup (so red OR blue) but AND based between groups ((red OR blue) AND size xs). Filters coming from a specific AttributeGroup are not applied to it when generating the aggregates. This means
If I have filter green from AttributeGroup color and filter xs from AttributeGroup size, then the aggregates for color should be generated by filtering by xs. size aggregates should be generated by filtering by green.
SingleValueFilters are mostly used for Boolean queries where allowing the user to select Yes and No together makes no sense.
The Result type is a convention borrowed from the Rust programming language and offers multiple benefits for TypeScript projects. It provides an alternative approach to traditional JavaScript and TypeScript error handling, which typically use exceptions. The Result type is defined as follows:
type Ok<T> = { ok: true; value: T };
type Err<E> = { ok: false; value: E };
export type Result<T, E> = Ok<T> | Err<E>;
export const ok = <T>(value: T): Ok<T> => ({ ok: true, value });
export const err = (value: ConnectorError): Err<ConnectorError> => ({ ok: false, value });
Here are the reasons why using a Result type can be beneficial in TypeScript:
Explicit Error Handling: The Result type enforces explicit error handling. Each function that can potentially fail will return a Result type, and if this isn't handled, the TypeScript compiler can be configured to give a warning. This stands in contrast to exceptions, where handling is implicit and easy to overlook, as the compiler does not enforce it.
Composable Error Handling: Result types provide a basis for composable error handling. You can implement a map
method, for instance, that modifies the successful result of a function but leaves an error unchanged. This provides a means for chaining operations that will halt upon encountering an error, simplifying the task of writing dependent computations.
Promotes Resilient Code: The explicit nature of the Result type encourages developers to anticipate and plan for potential errors. This can lead to more resilient code that is well-equipped to handle errors gracefully and effectively.
Improved Control Flow: With exceptions, the control flow can be difficult to follow, as it's not always clear where an exception might be thrown and caught. The Result type, however, provides a clear control flow: a function either returns a successful result or an error, which must be handled at the point where the function's Result is "unwrapped" (where you examine the result and respond differently based on whether it's a success or an error).
Adherence to Open/Closed Principle: When lower-level code throws an exception that must be caught by higher-level code, changes to the exceptions thrown can necessitate changes to the higher-level code's exception handling, violating the Open/Closed Principle. The Result type avoids this issue by making error handling explicit and a part of the function's contract, so that higher-level code doesn't need to change when new errors are introduced.
Testing is a vital part of connector development. We use Jest as a testing framework and focus on integration tests. The test coverage should be close to 100%. We also suggest using our internal utility package "@etribes/http-recorder", which automatically records/replays HTTP requests triggered by fetch
. This approach ensures that your connector is robust and behaves as expected under different conditions.
The usage of SDKs depends on the specific circumstances. Most services provide SDKs for their APIs, which can simplify the process of interacting with the service. For example, the Algolia Client handles outages and thereby increases the availability of the services. However, some SDKs provide little to no benefit or even introduce erratic behavior. In such cases, it might be more beneficial to use only the TypeScript types of the SDKs and implement the logic ourselves. Alternatively, you can use a generated client if the API offers an OpenAPI definition or uses GraphQL. Regardless of the approach, we highly suggest using the fetch
API so that the http-record package can be used.
Powered by Doctave