Getting Started
The fundamentals of Exobase
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.
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.
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
.
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.
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.
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.
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.
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.
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
)
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 }
})
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)
}
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)
}
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 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 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
)