An agnostic handler and light-weight middleware implementation.
Create simplistic and maintainable entrypoint-based applications.
For more information or raising issues please use the main phasma repository on GitHub.
The package is available on all registries and can be installed with your standard commands:
npm i @phasma/handler
This package on its own its not much use, its purpose is to provide a base for more tailored and technology specific variants to be created. If you are looking for pre-made technology packages then consider the following:
Below we explain the methodology and implementation details for working with custom technologies or existing providers that we have no official support for.
The crash course in the design of @phasma/handler
is:
-
A
Handler
contains the composition of services or business logic, these requireContext
to be available and are expected to return some kind ofResponse
format. -
Using
Middleware
you can provide additionalContext
(using data available through theProvider
) and transformResponse
formats to be compatible with theProvider
implementation. It is possible to alsotry-catch
and act like an error boundary for third-party monitoring tools. -
The
Context
is a data requirement by theHandler
or anyMiddleware
implementations. You can read into more advanced topics around context in understanding context below. -
A
Composition
is created from aHandler
with an optional series ofMiddleware
, these are chained together to create the flow of data in both directions. AllMiddleware
are called in order, passingContext
down the chain until finally theHandler
is invoked. Once theHandler
returns aResponse
it is passed back up the chain until it reaches theProvider
implementation. -
The
Provider
implementation wires theComposition
to a technologies interface allowing for it to be triggered with the expected inputs and return the expected outputs.
All of this is strictly type-safe and mus all be defined through typed first.
The Composition
has some advanced type-checking that validates that a given Handler
has the Content
and compatible Response
formats available, these are all provided through Middleware
or the Provider
itself.
Here are common topics:
- Creating custom providers and their implementations.
- Considerations for developer experience and how to improve it.
- How to define handlers and understanding their purpose.
- How to define middleware and understanding their purpose.
- More on understanding context in-depth.
A Provider
is a simple function that can perform the following:
function impl(composition: C): (...inputs: I) => O;
It is given a Composition
(of type C
) and is expected to return a functiont that is compatible with the technology being implemented against
In this case a function that takes in a number of inputs (of type I
) and outputs something (of type O
).
A real example of this is the aws-lambda
implementation available in @phasma/handler-aws
:
function impl(composition: C): (event: LE, contenxt: LC) => LO
This is compatible with
aws-lambda
as functions are invoked with an event (of typeLE
) as the first argument, then the second argument is the function context (of typeLC
) which is not to be confused with our concept ofContext
. The output (of typeLO
) is dependant on the given event, but for example, this could be a api gateway proxy result or nothing.
Other than the implementation, there are a few meta types that needed to be created that completes the integration.
I understand this documentation could be more complete, but for now digging around the @phasma/handler-aws
implementation should clear up any confusion (found here).
Once the Provider
implementation is complete its about creating a better developer experience.
This can be done through some abstractions to make usage easier or through creating some variations of @phasma/handler
base types with some options pre-provided.
One example is providing a function that returns a pre-typed Builder
for creating a Composition
with your Provider
.
In the @phasma/handler-aws
package we provide a function called aws()
that allows for the Builder
to be generated easily without needing to know the types to use.
We also provide a some string
alias representation of events that are supported that automatically type the Handler
and Middleware
definitions for us.
This allows for an implementation to be type input free and look like this:
export const handler = aws<'sqs'>((builder) => {
return builder
.use(new WithSomeMiddleware())
.use(new WithAnotherMiddleware())
.handler(new MyHandler());
});
Instead of having to know the internals of the library and supply the technology specific types for each handler like this:
export const handler = (new CompositionBuilder<SQSEvent, SQSResponse>())
.use(new WithSomeMiddleware())
.use(new WithAnotherMiddleware())
.handler(new MyHandler());
Although the first implementation has a few other benefits regarding lazy-loading and being able to provide additional data via the internal function arguments, they are much the same in what they are trying to do.
A handler is where the business logic and service composition happens. It is comparable to the controller in the Model View Controller (MVC) pattern.
To get the type-safety (which is the purpose of the library) we have to build a definition type up front with the expected inputs and outputs.
Here we introduce the Handler.Definition
which is defined as follows:
type Definition = Handler.Definition<P, C, R>;
Where:
-
P
is aProvider
type. This indicates to theHandler
what information it can get from the{ provider }
input parameter. -
C
is theContext
required for theHandler
to execute. This can be any object, however know that eitherProvider
has to satisfy this orMiddleware
need to introduce what is missing. This will cause build errors if not satisfied completely. -
R
is theResponse
type returned. This must be supported by theProvider
or viaMiddleware
that can handle its transformation. This will cause build errors if not satisfied completely.
This process can be simplified by composing your Context
from types defined along-side Middleware
whilst also maintaining common Response
types across your application.
When the Handler.Definition
is completed we can implement it as a class as follows:
class MyHandler implements Handler.Implementation<Definition> {
public async handle({ provider, context }: Handler.Fn.Input<Definition>): Handler.Fn.Output<Definition> {
return;
}
}
Of course, as you have full control of the class you can do dependency injection through the constructor and fully test your handler.
Much like defining handlers the definition of Middleware
is done through types first and then code implementation last.
But before we get into defining a Middleware
definition type we should consider what Middleware
can do and what we want to achieve with it.
- Can provide additional
Context
down chain. - Can enable custom
Response
types from down chain. - Can operate on the
Provider
input data. - Can return early, short-circuiting and bypassing the
Handler
. - Can act as an error boundary through
try-catch
.
Knowing this, our Middleware.Definition
type is a little more complex as it needs to define both inbound and outbound types for both Context
and Response
if they are used.
It is possible to "inherit" values using the Middleware.Definition.Inherit.*
types in their correct positions.
Here is an example of a middleware definition that does nothing type wise, being completely passive and having no affect on the composition:
type Definition = (
Middleware.Definition<
Middleware.Definition.Inherit.Provider,
Middleware.Definition.Inherit.ContextInbound,
Middleware.Definition.Inherit.ContextOutbound,
Middleware.Definition.Inherit.ResponseInbound,
Middleware.Definition.Inherit.ResponseOutbound
>
);
Here is an example of a middleware that would add a random number to all future context:
type Definition = (
Middleware.Definition<
Middleware.Definition.Inherit.Provider,
Middleware.Definition.Inherit.ContextInbound,
{ readonly random: number },
Middleware.Definition.Inherit.ResponseInbound,
Middleware.Definition.Inherit.ResponseOutbound
>
);
The
...Inherit.ContextOutbound
has been replaced with an object{ readonly random: number }
which will be merged into the activeContext
of theComposition
. OtherMiddleware
can define this in theirContextInbound
and as long as they are defined later with.use()
there will be no type errors. Like-wise for theHandler
, it can now require this random number in its context and as long as the implementation of thisMiddleware
is used the types will build correctly.
Here is a middleware that requires a random number and returns whether the number is odd as context:
type Definition = (
Middleware.Definition<
Middleware.Definition.Inherit.Provider,
{ readonly random: number },
{ readonly isOdd: boolean },
Middleware.Definition.Inherit.ResponseInbound,
Middleware.Definition.Inherit.ResponseOutbound
>
);
Note that the
{ readonly random: number }
has moved up toContextInbound
, making it a requirement for thisMiddleware
. Like-wise we return new context, without the random number. This doesn't mean the random number is no longer available, it merged and always available to theComposition
it is used with.
The ResponseInbound
and ResponseOutbound
parameters work the same.
The implementation is very similar to Handler
but instead we have an invoke()
method and an additional { next }
parameter given as input:
class MyMiddleware implements Middleware.Implementation<Definition> {
public async invoke({ provider, context, next }: Middleware.Fn.Input<Definition>): Middleware.Fn.Output<Definition> {
return next(context);
}
}
Again, like Handler
implementations you have control of the constructor for dependency injection and testability.
There are a few rules to obey when writing the implementation:
-
A middleware must call
next()
unless it wishes to short-circuit the composition. This must be done by providing a valid response that is compatible with theProvider
or another middleware that was defined prior. -
The
next()
function must be called with the given context merged with any additional context that is defined by types. This ensures that context is always available and you handle the priority of merging your new context in. -
The
next()
can be wrapped intry-catch
to act as an error boundary. Again, ensure you return a valid response that is compatible with theProvider
or another middleware that was defined prior. -
The response from
next()
must always be returned, even if you are testing out a custom response you cannot guarantee another response that your middleware is un-typed for is not going to be passed down. This ensures other middleware can handle their responses and the concern of your middleware is only its targetted response.
An important design decision when building Middleware
is to make small and concise units that provide small amounts of context.
These can be composed and contribute to the contextual needs of the Handler
as context is merged from all middleware.
This allows middleware implementations to be easily testable and re-usable by making use of their constructors.
If you have read defining handlers and defining middleware you may have a better understand of what we mean by context.
It can be better thought of as required parameter input for a Handler
or Middlware
to operate.
We do this by merging the Middleware
outbound context types as part of the Composition
chaining, allowing context to "be built up" as middleware as added with .use()
, this then is used by the Handler
to ensure its context is fulfilled.
That is, a
Handler
might only define a partial amount of context that is "over provided" byMiddleware
, but theHandler
is fine with that. Obviously, aHandler
requiring more context than it is getting fromMiddleware
will result in build errors.
To run through a visual example, lets look at a more fragmented composition and how its types are built up within the composition:
(new CompositionBuilder())
// composed context is empty to start with
// unless a provider provides some base context
.use(new WithQueryParameters<Q>())
// provides { query: Q } to context
// composed context looks as follows:
// { query: Q }
.use(new WithPagination())
// requires { query: Record<string, string | undefined> }
// provides { pagination: { limit: number, page: number } }
// composed context looks as follows:
// { query: Q; pagination: { .. } }
.use(new WithBody<B>())
// provides { body: B }
// composed context looks as follows:
// { query: Q; pagination: { .. }; body: B }
.handle(new MyHandler());
// requires { authorised: boolean; body: B; pagination: { .. } }
// build error, missing authorised in required context!
// none of the middleware provide it
// lets "undo" and add the missing middleware ..
.use(new WithAuthorisation())
// provides { authorised: boolean; user: { .. } }
// composed context looks as follows:
// { query: Q; pagination: { .. }; body: B; authorised: boolean; user: { .. }; }
.handle(new MyHandler());
// requires { authorised: boolean; body: B; pagination: { .. } }
// now it works!
// note it only requires a partial amount of context too
The bonus now is that the Handler
has all this work done for it and can safely assume that context will always be provided because of the type-safety.