Exobase
  1. Getting Started
  2. Core Concepts

Exobase is a web framework that aims to solve the ility that nobody ever talks about: nimbility. The ability to be nimbie, to change easily, is paramount. I started Exobase in 2018, not as a library or framework, but as a personal exploration to answer the question:

how can I write web services and APIs without binding my code to the framework or having to rewrite my code if I need to change frameworks?

The core concepts of Exobase make up a design pattern called Abstract & Compose which was the eventual solution to my question.

The Belief

All things that work in a request/response format can be boiled down to essentially the same thing: a request, some work, a response. Exobase provides a simple pattern to convert any request/response framework into a standard format that you can plug your existing Exobase code into.

The Format

When a request is handled, something should convert the request into a standard shape. We call this framework mapping, some devs think of it as a transform step, either way. Now that we have a standard shape we can build modules that depend on that shape — not the framework. For example, the useExpress module maps/transforms the (req, res) input from the Express framework into the Exobase standard Props input. The other modules, like useJsonArgs and useCors, do not depend on (req, res) they depend on the standard Props.

Props

The standard shape we use is called Props. It’s an immutable (or should be treated as such) object that contains request, response, args, services, auth, and framework. These are the core elements shared across any request/response framework.

Request

The request property on the Props object contains metadata about the request that was handled. It’s the responsibility of the framework mapping module (a.k.a root hook, more on that later) to parse the framework inputs into a request object.

Response

The response exists for convinence. This is not a mutable object like the res object in Express is. It’s a simple object with a few default properties to help you create a more specific response if you need to by extending it.

import type { Props } from '@exobase/core'

export const endpoint = async ({ args, response }: Props) => {
  if (!args.id) {
    return {
      ...response,
      status: 400,
      body: {
        message: 'failure'
      }
    }
  }
  await db.work(id)
  return {
    message: 'success'
  }
}

The framework mapping/transforming module (a.k.a root hook, we’re almost there) is responsible for parsing the result of an endpoint function and applying it to the framework properly. All Exobase provided root hooks apply the function responses in the same format.

Args

In the spirit of security, code cleanliness, and testability the Props object has an args property that is used to hold all request arguments. What is a request argument? It’s anything your endpoint function needs or uses as input to do it’s dedicated task.

All request input is provided raw, unparsed, and unvalidated by the root hook in the request property. As the handler does work, parsing and validating the request, it should populate the args objects with values that are safe for use in the endpoint.

The arguments can come from any part of a request. Most commonly we parse args from the body, headers, path, and query string of a request. Exobase provides hooks out of the box for these.

import { compose } from 'radash'
import type { Props } from '@exobase/core'
import { useExpress } from '@exobase/use-express'
import {
  useJsonArgs,
  useQueryArgs,
  useHeaders,
  usePathParams
} from '@exobase/hooks'

export const endpoint = async ({ args, response }: Props) => {
  console.log(props)
  // {
  //   name: 'exo',
  //   'x-trace-id': 'xtid.2381',
  //   segment: 'start',
  //   id: '9'
  // }
}

const handler = compose(
  useExpress(),
  useJsonArgs(z => ({
    name: z.string()
  })),
  useQueryArgs(z => ({
    id: z.string()
  })),
  useHeaders(z => ({
    'x-trace-id': z.string()
  })),
  usePathParams('/endpoint/{segment}'),
  endpoint
)

// curl -X POST -d `{"name":"exo"}` -h 'x-trace-id=xtid.2381' /endpoint/start?id=9

You could easily read data directly from the request property of Props but as a best practice it’s highly recommended that you treat the request as untrusted and the args as trusted.

Services

The services object is Exobase’s mini dependency injection solution. This isn’t your grandpa’s DI, this is built soley for testability. There are no tools for providing a implementation of an interface at runtime.

When testing your endpoint functions, you can use services as a place to pass the modules your function depeneds. Then, in your tests, you can pass mocks of those modules.

Exobase provides the useServices hook to inject things into the services.

import { compose } from 'radash'
import type { Props } from '@exobase/core'
import { useLambda } from '@exobase/use-lambda'
import { useServices, useQueryArgs } from '@exobase/hooks'

export const getBookById = async ({ services, args }: Props) => {
  const { database } = services
  return await database.books.findById(args.id)
}

const handler = compose(
  useExpress(),
  useQueryArgs(z => ({
    id: z.string()
  })),
  useServices({
    database: async () => {
      const d = new Database()
      await d.connect()
      return d
    }
  }),
  getBookById
)

One of the goals of Exobase is to isolate the business logic, bring it to the front, and push everything else to the back. Using the services with the useServices hook helps us keep our endpoint function (where the business logic is) pure and easy to test.

Auth

Like args and services the auth property on the Props is a placholder object you can use to store authentication data about the current request. Auth hooks (like useTokenAuth, useBasicAuth, and useApiKey) do not populate the args, they populate the auth property.

import { compose } from 'radash'
import type { Props } from '@exobase/core'
import { useLambda } from '@exobase/use-lambda'
import { useApiKey } from '@exobase/hooks'

export const getBookById = async ({ args, auth }: Props) => {
  console.log(args) // {}
  console.log(auth) // { apiKey: 'my-little-secret' }
}

const handler = compose(
  useExpress(),
  useApiKey('my-little-secret')
  getBookById
)

Framework

The framework property on the Props object exists as an escape hatch for use cases Exobase doesn’t provide a solution for. All root hooks set framework as the arguments that they were originally provided.

A few examples, in Express:

compose(useExpress(), async ({ framework }) => {
  console.log(framework) // { req, res }
})

in Lambda:

compose(useLambda(), async ({ framework }) => {
  console.log(framework) // { event, context }
})

in Next.js:

compose(useLambda(), async ({ framework }) => {
  console.log(framework) // { req, res }
})

Root Hooks

In simple terms, Exobase is just a middleware library with a standard input format. Hooks, are small modules that act like middleware. During a request a hook they receive the current Props object, optionally do work, call the next hook in the chain with possibly modified or extended Props, and optionally do something with the response of the next hook before returning.

Root hooks are the modules/functions responsible for mapping/transforming the framework specific input arguments into standard Props and then applying the result back to the specific framework.

Root hooks have the same general shape. Like any hook they take options, then they take the next function in the chain, then they take the framework arguments.

type RootHook =
  (options?) =>
  func =>
  async (...args) =>
    any

As an example, here’s an abbreviated look at the useLambda hook:

const useLambda =
  (options?: UseLambdaOptions) => (func: Handler) => async (event, context) => {
    const props = lambdaProps(event, context)
    const [err, result] = await tryit(func)(props)
    return lambdaResponse(err, result, event, context)
  }

Hooks

For any given endpoint you’ll only ever use one single root hook. However, you might use many hooks. Hooks are shapped a bit different from root hooks, they don’t expect framework arguments when called, they expect the standard Props (the root hook should create the Props and pass them down to the hooks).

type Hook =
  (options?) =>
  func =>
  async (props) =>
    any

Here’s a simplified look at the useCors hook:

const useCors = options => func => async props => {
  if (props.request.method === 'OPTIONS') {
    return {
      ...props.response,
      headers: CORS_HEADERS
    }
  }
  return await func(props)
}

Init Hooks

There’s one more type of hook, init hooks. These are hooks that have some initializing job, usually a side effect on the environment, that should run before each endpoint. They don’t assume anything about the inputs or make any changes to them.

Here’s a very simplified version of the useConsoleIntercept hook:

const useConsoleIntercept =
  options =>
  func =>
  (...args) => {
    console.log = options.logger.log
    console.warn = options.logger.log
    console.error = options.logger.log
    return await func(...args)
  }

Endpoints

Endpoints are the function that contains the business logic. These are always the last function on the function composition. They do not take configuration or a next func.

type Endpoint = (props: Props) => Promise<any>

Ideally, this format makes endpoint functions easy to test.

import { compose } from 'radash'
import type { Props } from '@exobase/core'
import { useLambda } from '@exobase/use-lambda'
import { useServices, useQueryArgs } from '@exobase/hooks'

// The endpoint function
export const getBookById = async ({ services, args }: Props) => {
  const { database } = services
  return await database.books.findById(args.id)
}

const handler = compose(
  useExpress(),
  useQueryArgs(z => ({
    id: z.string()
  })),
  useServices({
    database: makeDatabase
  }),
  getBookById
)

Composition

Composition is not difficult, it’s so easy we don’t even provide it. All our project use the radash compose function but you can easily write it yourself.

export const compose = (...funcs: Function[]) => {
  return funcs.reverse().reduce((acc, fn) => fn(acc))
}

The patterns you’ve seen with hooks, root hooks, init hooks and endpoints all come together when composed.

const handler = compose(
  useConsoleIntercept({ logger }), // init hook
  useLambda(),               // root hook
  useApiKey('secret')        // hook
  useCors(),                 // hook
  listBooks                  // endpoint
)

With composition, if you decide to allow public access to your function simply remove the useApiKey('secret') hook from the composition.

const handler = compose(
  useConsoleIntercept({ logger }), // init hook
  useLambda(), // root hook
  useCors(), // hook
  listBooks // endpoint
)