1.0.0 • Published 1 year ago

@phasma/handler v1.0.0

Weekly downloads
-
License
-
Repository
github
Last release
1 year ago

@phasma/handler

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.

Getting Started

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:

  • @phasma/handler-aws for use with lambda on aws (github | npm)

Integration Documentation

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 require Context to be available and are expected to return some kind of Response format.

  • Using Middleware you can provide additional Context (using data available through the Provider) and transform Response formats to be compatible with the Provider implementation. It is possible to also try-catch and act like an error boundary for third-party monitoring tools.

  • The Context is a data requirement by the Handler or any Middleware implementations. You can read into more advanced topics around context in understanding context below.

  • A Composition is created from a Handler with an optional series of Middleware, these are chained together to create the flow of data in both directions. All Middleware are called in order, passing Context down the chain until finally the Handler is invoked. Once the Handler returns a Response it is passed back up the chain until it reaches the Provider implementation.

  • The Provider implementation wires the Composition 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:

Custom Providers

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 type LE) as the first argument, then the second argument is the function context (of type LC) which is not to be confused with our concept of Context. The output (of type LO) 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).

Developer Experience

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.

Defining Handlers

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 a Provider type. This indicates to the Handler what information it can get from the { provider } input parameter.

  • C is the Context required for the Handler to execute. This can be any object, however know that either Provider has to satisfy this or Middleware need to introduce what is missing. This will cause build errors if not satisfied completely.

  • R is the Response type returned. This must be supported by the Provider or via Middleware 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.

Defining Middleware

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 active Context of the Composition. Other Middleware can define this in their ContextInbound and as long as they are defined later with .use() there will be no type errors. Like-wise for the Handler, it can now require this random number in its context and as long as the implementation of this Middleware 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 to ContextInbound, making it a requirement for this Middleware. 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 the Composition 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 the Provider 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 in try-catch to act as an error boundary. Again, ensure you return a valid response that is compatible with the Provider 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.

Understanding Context

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" by Middleware, but the Handler is fine with that. Obviously, a Handler requiring more context than it is getting from Middleware 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.

1.0.0

1 year ago

1.0.0-next.1

1 year ago

0.4.0-rc.1

2 years ago

0.3.1

2 years ago

0.3.0

2 years ago

0.2.2

2 years ago

0.2.1

2 years ago

0.2.0

2 years ago

0.1.1

2 years ago

0.1.0

2 years ago