Building your own adapter

Background

Hyper is fundamentally a service framework, although at first, this may not seem obvious.

Hyper service APIs are broken up into broad application use cases referred to as Ports. For each Port that Hyper defines, Hyper exposes an interface that allows applications to fulfill that use case, by consuming Hyper's simple interface.

For example, Hyper has defined a Data port for storing, retrieving, and indexing structured data and a Search port for full-text search capabilities. A list of all supported ports can be found on the Hyper homepage. Hyper does not implement these capabilities. Instead, Hyper wraps 3rd party services that can be used to fulfill the use cases for the given Port (the reason for why they're called Ports will hopefully become clearer).

For example, Hyper's Data port may wrap any database like postgres, mongoDB, or mySQL, or any managed service like AWS DynamoDB, MongoDB Atlas, Firestore, or GCP CloudSQL. Because Hyper provides an API to consume, then maps those requests onto underlying external service calls, it is appropriate to call Hyper a service API framework.

With Hyper, instead of your application code integrating directly with one of a myriad of managed services, and thus is coupled to that service or cloud, your code integrates with Hyper's consistent interface, allowing you to mix, match, and even swap. Hyper then handles the nuances of interfacing with any service for you.



Document image

So how does Hyper know how to interface with the literal hundreds of service offerings and then still provide a consistent API for each of its defined Ports? This is where Hyper adapters come in.

How does hyper utilize adapters?

Hyper Ports are called such because hyper is built using the "Ports and Adapters" architectural pattern. This pattern promotes creating loosely coupled components that can be more seamlessly integrated and more easily interchangeable.

The hyper core defines the Ports. Hyper core also defines the API that each adapter must implement in order to be leveraged for that Port. Hyper core, given a set of adapters, will create a service layer that it then uses to interface with external services.

In this way, Hyper core knows nothing about the nuances of each service it seeks to wrap. That responsibility is delegated to adapters that implement a Port interface. As a result, hyper core is kept lean, only consisting of the port definitions, code that validates adapter Port compliance, and composing adapters into a service tier. In the example above, there may be Data port adapters for postgres, mongoDB, and mySQL. hyper chooses the adapter it needs depending on which service it is seeking to use to fulfill a Port's capabilities.

Document image



So an adapter is a complete implementation of a core-defined Port interface. An adapter is where the "meat" of an external service integration lies, and implementation will look different for each adapter. Though the structure used to provide hyper with an adapter is always the same.

hyper defines a factory interface that will need to be implemented in order to use an adapter with hyper. Additionally, the way hyper constructs an adapter enables powerful approaches for combining factories to create complex adapter functionality.

Adapter factory structure

Adapters are provided to hyper through the use of factories. A factory is an object that implements one method: link. There are other optional fields, but their types are enforced if defined. Here are the types for a hyper adapter factory:

TypeScript

In the examples below, for the sake of brevity, the factory examples given below will implement an imaginary Port, "echo", that requires an "echo" adapter to implement the following interface:

TypeScript

Once a factory is defined it can be provided to hyper through the hyper.config.js file:

TypeScript

Assuming your factory provides a valid implementation of the "echo" port, hyper now has what it needs to use your adapter in the Hyper service framework.

An adapter factory link and load functions are called by Hyper, when the Hyper service starts up, to construct the service tier Hyper core then uses to interface with external services. Let's take a moment to understand what each of these functions is meant to do.

load

The load function on an adapter factory is called by hyper on startup. Theload takes an object as a parameter and then returns an object. The output of load is what hyper will pass to the link function.

load is meant to be used to prepare any configuration your adapter will need during the link phase. Let's say we have an adapter that needs to read values from an environment and set some configuration. We can do this in the load function:

TypeScript

Now when hyper calls the link function next, it will pass the { timeout, host, port } as a parameter to it. If load is not implemented, Hyper will simply pass undefined to the link function.

The link function on an adapter factory is called by hyper on startup, directly after load. This is where your factory will ultimately need to provide an implementation of a Port interface. Recall the signature of the link:

TypeScript

We can see that the link accepts a config which is the result of the load function or undefined if theload is not provided. Then what is returned is another function that looks like this:

TypeScript

This may seem strange at first glance. Recall the shape of each object in the array passed to adapters in the hyper.config.js

TypeScript

Notice what is provided to plugins is an array of factories, for each port. This suggests that hyper allows passing more than one factory, which it does!

TypeScript

Underneath the hood, Hyper composes the functions returned from link, using the "onion" principle. Each of these functions is passed next which is the result of the next link in the array passed to plugins.

This means each link wraps the next link in the chain! Each link can choose to call the next link, or just return data which then travels back up the chain! This enables powerful approaches for combining factories to produce complex adapter behavior

Call flow through adapter links
Call flow through adapter links

Links that do not call anything on next are referred to as "Terminating Links" because they do not propagate data "down" the chain and instead return data back "up" the chain. A link chain will need to have at least one terminating link to be implemented, for each Port method. For the last link in the chain, next will be an empty object.

We will dive more into what can be done with this later. For now, just understand that this composition is the reason for the interesting signature of link. As a general rule of thumb, your adapter factory link probably will not need to use next if it is able to be used with a given Port, on its own.

Adapter factory lifecycle

When the hyper service first starts, it will evaluate a provided hyper.config.js file. Then for each adapter definition in the adapters array, hyper will grab the array of factories passed to plugins, and call their load functions, if implemented, passing the output of each load function into the next load function.

Once all load functions have been called, the result is then passed into each link function. This will produce a list of functions that Hyper then "chains" together, passing each link to the next link in the chain as next. The composition of these links is an object. That is then parsed and wrapped to ensure the Port interface is implemented.

Writing tests for an adapter

Adapters in hyper are written to run on Deno. Thus, adapters should use Deno for running tests. Please review the Deno testing manual on how to run tests using Deno.

A common pattern in hyper is to set up a scripts/ folder in your adapter repo that contains common scripts to run. You may have a test.sh file that then checks code for formatting, and runs deno test. An example can be found here.

Contribution Templates

An adapter template can be found here. Be sure to update the {{ADAPTER_NAME}} in mod.js. Right now, the template is scaffolding for the Data Port adapter. In the future, we'd like these templates to be more dynamic and able to spin up the adapter scaffolding for any Port. PRs are welcome!

Each Port schema that an adapter must implement can be found in https://github.com/hyper63/hyper63/blob/main/packages/port-{{name}}/mod.js

Port Schemas

hyper parses schemas of both adapters and factories using a library called Zod. Zod schemas are human-readable, and are used by hyper to wrap adapters at runtime to verify inputs, outputs, and shape of each implemented method to ensure the Port interface is properly implemented.

You may want to verify the shape of your adapter or factory in a test. You use hyper's schemas to validate your factory in a unit test:

JS

and also verify an adapter properly implements a given port:

JS

Each Port schema can be found in https://github.com/hyper63/hyper63/blob/main/packages/port-{{name}}/mod.js

Best practices for building and testing your new adapter

Easy to test

Adapters should be easy to unit test. Consider using patterns like dependency injection to make adapters easier to test in a mocked environment:

JS

Separate dependencies and dev dependencies

separate dependencies and dev dependencies in deps.js and dev_deps.js respectively. This is a best practice for Deno projects