Skip to content

Commit

Permalink
feat(host,build): add docs on Custom Adapters, Config file, and hosti…
Browse files Browse the repository at this point in the history
…ng hyper
  • Loading branch information
TillaTheHun0 committed Jan 2, 2024
1 parent 8ecf681 commit 78715f3
Show file tree
Hide file tree
Showing 8 changed files with 376 additions and 8 deletions.
2 changes: 1 addition & 1 deletion src/docs/api-reference/index.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# API Reference

The hyper Service Framework is built using the
[Ports and Adapters](/docs/concepts/ports-and-adapters) architecture, and so can be presented using
[Ports and Adapters](/docs/concepts/clean-cloud-architecture) architecture, and so can be presented using
any [hyper Driving Adapter aka. "App"](/docs/build/custom-app) implementation.

For example, a hyper App may choose to expose an HTTP-based RESTful API, for consuming the hyper
Expand Down
2 changes: 1 addition & 1 deletion src/docs/api-reference/rest/index.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# API Reference (HTTP-based REST 🛌)

The hyper Service Framework is built using the
[`Ports and Adapters`](/docs/concepts/ports-and-adapters) architecture, and so can be presented (and
[`Ports and Adapters`](/docs/concepts/clean-cloud-architecture) architecture, and so can be presented (and
consumed) using any [hyper Driving Adapter aka. `App`](/docs/build/custom-app) implementation.

However, a common presentation of hyper is an HTTP-based RESTful API. The hyper Core team maintains
Expand Down
222 changes: 220 additions & 2 deletions src/docs/build/custom-adapter.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,221 @@
# Build a Custom hyper Driven Adapter
# Custom hyper Adapter

TODO
A [hyper Driven Adapter](/docs/concepts/clean-cloud-architecture#hyper-adapters-🛠) implements one of Ports defined in hyper `Core`.

The current `Ports` in hyper are `Data`, `Cache`, `Storage`, `Queue`, and `Search`.

:::info
Generally we refer to an `Adapter` that implements the `Data` port as a `Data Adapter`
:::

The Core team has [implemented many `Adapters`](https://github.com/hyper63?q=hyper-adapter&type=all&language=&sort=), but you can also build your own! For example, maybe you need to power a hyper `Service` using technology not already implemented by an existing `Adapter`, or maybe there are specific use-cases where a custom adapter is needed.

In any case, any implementation of a `Port` can be given to hyper `Core` via the [hyper Config File](/docs/host/hyper-config). If the `Port` is correctly implemented, hyper will use it!

:::info
In this sense, hyper `Core` simply consumes an API. It's the `Adapters` job to implement that API
:::

## Adapter Plugins

So an adapter is a complete implementation of a core-defined `Port`. 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 plugin interface that will need to be implemented in order to use it with a hyper `Port`

:::info
`Adapters` technically implement the `plugin` interface, whose `link` method then builds the implementation for the specific `Port`
:::

### Structure

`Adapters` are provided to hyper through the use of plugins. A hyper `Adapter` plugin is an object that implements one method: `link`. There are other optional fields, and their types are enforced if defined. Here are the TypeScript types for a hyper adapter plugin:

```ts
type HyperPortAdapterConfig = Record<string, unknown>;

type HyperPortAdapter = Record<String, Function>;

type AdapterPluginLoad = (
config: any
) => HyperPortAdapterConfig | Promise<HyperPortAdapterConfig>;

type AdapterPluginLink = (
config: HyperPortAdapterConfig
) => (next: HyperPortAdapter | null) => HyperPortAdapter;

export interface HyperAdapterPlugin {
id?: string;
port?: "storage" | "data" | "cache" | "search" | "queue";
load?: AdapterPluginLoad;
link: AdapterPluginLink;
}
```

For the sake of brevity, the plugin examples given below will implement an imaginary `Port`, "`echo`", that requires an adapter to implement the following interface:

```ts
export interface EchoAdapter {
echo({ name }: { name: string }): Promise<string>;
}
```

Once a plugin is defined it can be provided to hyper through the [hyper Config File](/docs/host/hyper-config):

```ts [hyper.config.js]
const echoAdapterPlugin = {
link: () => () => ({
echo: ({ name }: { name: string }) => Promise.resolve(`Hello ${name}`)
})
}

export default {
...,
adapters: [
{ port: 'echo', plugins: [echoAdapterPlugin] }
...
]
}
```

Assuming your plugin provides a valid implementation of the "echo" port, hyper now has what it needs to use your adapter in the hyper Service Framework.

### Adapter Plugin `load` and `link`

An adapter plugin's `link` and `load` functions are called by hyper, when the hyper `Server` is bootstrapping, to construct the `Adapters` hyper `Core` then uses to communicate with external services. Let's take a moment to understand what each of these functions are meant to do.

#### `load`

The `load` function on an adapter plugin is called by hyper on startup. The `load` function takes an object as a parameter and then returns an object or a `Promise<object>`. The output of `load` is what hyper will pass to the `link` function.

The purpose of `load` is 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 the environment and set some configuration. We can do this in the load function:

```ts
const echoAdapterPlugin = {
load: () => {
const timeout = parseInt(Deno.env.get("ECHO_TIMEOUT") || "60000");
const host = Deno.env.get("ECHO_HOST");
const port = Deno.env.get("ECHO_PORT");

return { timeout, host, port };
},
};
```

Since `load` can also return a `Promise`, it can be used for Asynchronous setup as well ie. fetching credentials from an endpoint in a secured VPC.

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

#### `link`

The `link` function on an adapter plugin is called by hyper on startup, directly after `load`. This is where your plugin will ultimately need to return an implementation of a hyper `Port`.

### Advanced: Composing Plugins to Build Full Adapters

Recall the signature of the `link`:

```ts
type HyperPortAdapterConfig = any;

type HyperPortAdapter = Record<String, Function>;

type AdapterPluginLink = (
config: HyperPortAdapterConfig | undefined
) => (next: HyperPortAdapter | undefined) => HyperPortAdapter;
```

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

```ts
(next: HyperPortAdapter | undefined) => HyperPortAdapter;
```

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

```ts
const echoAdapterPlugin = {
link: () => () => ({
echo: ({ name }: { name: string }) => Promise.resolve(`Hello ${name}`)
})
}

export default {
...,
adapters: [
{ port: 'echo', plugins: [echoAdapterPlugin] } // an array of plugins
...
]
}
```

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

```ts
const echoAdapterPlugin = {
link: () => () => ({
echo: ({ name }: { name: string }) => Promise.resolve(`Hello ${name}`)
})
}

const loggingEchoPlugin = {
link: () => next => {
console.log("Look Ma', composition!")

return {
echo: ({ name }) => {
console.log(`echoing now with name ${name}`)
return next.echo({ name })
}
}
}
}

const exclamationEchoPlugin = {
link: () => next => {
console.log("Look Ma', more composition!")

return {
echo: async ({ name }) => {
const greeting = await next.echo({ name })
return `${greeting}!`
}
}
}
}

export default {
...,
adapters: [
{
port: 'echo',
plugins: [
loggingEchoPlugin,
exclamationEchoPlugin,
echoAdapterPlugin
]
}
...
]
}
```

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 an API on the next `link`, or just return data which then travels back up the chain! This enables powerful approaches for combining plugins to produce complex adapter behavior.

![hyper plugin chain link](/link-chain.jpeg)

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` defined API. 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`.

:::tip
Plugin Linking is an advanced concept; you probably don't need it.

As a general rule of thumb, your `Adapter` probably will not need to use `next` if it fully implements the `Port` and does not call `next`
:::

### Adapter Plugin Lifecycle

When the hyper service first starts, it will evaluate a provided [hyper Config File](/docs/host/hyper-config). Then for each `Adapter` definition in the `adapters` array, hyper will grab the array of plugins passed to plugins, and call their `load` functions, if implemented, passing the output of each `load` function into the next `load` function. The final result is the final value passed to `link`.

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 result of this composition is an object. That object is then wrapped with the corresponding hyper defined `Port`, to ensure it properly implements the `Port`.
2 changes: 1 addition & 1 deletion src/docs/build/index.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Build on hyper

The hyper Service Framework is built using the
[Ports and Adapters](/docs/concepts/ports-and-adapters) architecture. Hyper's core business logic is driven by a Driving Adapter
[Ports and Adapters](/docs/concepts/clean-cloud-architecture) architecture. Hyper's core business logic is driven by a Driving Adapter

The general idea with Ports and Adapters is that the business logic layer defines the models and rules on how they interact with each other, and also a set of consumer-agnostic entry and exit points to and from the business layer. These entry and exit points are called "Ports". All components _external_ to the business layer, interact by way and are interacted with through, the Ports. A Port defines an api, and knows nothing about the inner mechanism -- that's an Adapter's job.

Expand Down
2 changes: 1 addition & 1 deletion src/docs/build/securing-hyper.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Here is what that middleware might look like, when using the pre-built App [`app
::: code-group

```ts [hyper.config.ts]
import { appExpress, hyper, type express } from './deps.ts'
import { appExpress, hyper, jwt, type express } from './deps.ts'
/**
* Given a sub and secret, return a hyper Custom Middleware that will
* check that all incoming requests have a properly signed jwt token
Expand Down
104 changes: 103 additions & 1 deletion src/docs/host/hyper-config.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,105 @@
# The `hyper` Config File

TODO
The config file is the core manifest for hyper, it pulls all of the adapters into the fold and configures, and applies them to the core module.

The config file is where you choose the `App` and set of `Adapters` you would like hyper `Core` to use. The hyper Service Framework uses a config file to bootstrap the hyper `Server` with the provided `App` and `Adapter`.

A Config file will look like this:

::: code-group

```js [hyper.config.js]
// hyper driving adapter
import express from "https://raw.githubusercontent.com/hyper63/hyper/hyper-app-express%40v1.2.1/packages/app-express/mod.ts";

// hyper driven adapters
import mongodb from "https://raw.githubusercontent.com/hyper63/hyper-adapter-mongodb/v3.3.0/mod.ts";
import redis from "https://raw.githubusercontent.com/hyper63/hyper-adapter-redis/v3.1.2/mod.js";

import { auth } from "./auth.ts";

export default {
app: express,
adapters: [
{ port: "data", plugins: [mongodb({ url: Deno.env.get("MONGO_URL") })] },
{ port: "cache", plugins: [redis({ url: Deno.env.get("REDIS_URL") })] },
// ... any other adapters
],
middleware: [auth],
};
```

```ts [auth.ts]
import { jwt, type express } from "./deps.ts";

/**
* Given a sub and secret, return a hyper Custom Middleware that will
* check that all incoming requests have a properly signed jwt token
* in the Authorization header as a bearer token
*/
export const auth =
({ sub, secret }: { sub: string; secret: string }) =>
(app: express.Express) => {
/**
* Extract the bearer token from the header, and verify it's
* signature and sub matches expected
*/
const verify = async (header: string) => {
const payload = await jwt
.verify(header.split(" ").pop() as string, secret, "HS256")
.catch(() => {
throw { name: "UnauthorizedError" };
});
/**
* Confirm sub matches
*/
if (payload.sub !== sub) throw { name: "UnauthorizedError" };
};

app.use(async (req, _res, next) => {
await verify(req.get("authorization") || "Bearer notoken")
.then(() => next())
// pass error to next, triggering the next error middleware to take over
.catch(next);
});

app.use(
(
err: unknown,
_req: express.Request,
res: express.Response,
next: express.NextFunction
): unknown => {
if (err && err.name === "UnauthorizedError") {
return res.status(401).send({ ok: false, msg: "not authorized" });
}
// Trigger the next error handler
next(err);
}
);

return app;
};
```

:::

You can then invoke hyper `Core` in the same directory using `Deno`:

```sh
deno run -A https://raw.githubusercontent.com/hyper63/hyper/hyper%40v4.3.2/packages/core/mod.ts
```

This will download hyper `Core`, load your configuration file, and start up a hyper `Server` using the `App`, `Middleware` and `Adapters` you've specified in the config.

## Start hyper Programmatically

You can also start hyper by importing it using `Deno`

```ts
import hyper from "https://raw.githubusercontent.com/hyper63/hyper/hyper%40v4.3.2/packages/core/mod.ts";

hyper({
// your hyper config here
});
```
Loading

0 comments on commit 78715f3

Please sign in to comment.