Getting started

How to extend Tensei applications using plugins

A plugin may be used to extend Tensei. There's a ton of things you can do with plugins. We have many official plugins such as auth, rest, graphql, and media. This shows you how powerful plugins can be.

To get a good understanding of plugins, you'll need to understand the life cycle of a Tensei application.

Application lifecycle

When a Tensei app is started, it goes through the following steps:

  1. Registration of all resources, fields, and middleware.
  2. Registers all plugins. All plugins provide a callback called register. At this point, plugins can add resources, fields, middleware, hooks, assets and a lot more to the Tensei application.
  3. Establishment of database connection and generating the ORM.
  4. Booting all plugins. All plugins provide a callback called boot. At this point, plugins have access to the database and also have access to all other resources, hooks, fields registered by other plugins.
  5. Register all routes on the Express application.
  6. Register all event listeners & error handlers.
  7. Starting the application server.

Creating a plugin

You may create a plugin by calling the plugin function from Tensei. Let's create an example plugin called blog.

import { tensei } from '@tensei/core'
import { plugin } from '@tensei/common'

// Create a plugin
const blog = plugin('Blog Plugin')

// Register a plugin on a Tensei application
export default tensei()
    .plugins([
        blog
    ])

This plugin will help any member of the Tensei community setup a blog in a few minutes. To do this, here's what our plugin will do:

  1. Register all the resources needed for a fully powered blog
  2. Seed the database with default blog data such as categories and tags
  3. Add a REST API route for fetching blog statistics
  4. Add a GraphQL API query for fetching blog statistics
  5. Whenever a new user joins the application, send an email to them with all the blog statistics
  6. Add a custom WYSIWYG editor to replace the default textarea field in the CMS.

Let's get started.

Registering resources

First let's define our blog resources:

import { resource, text, textarea, hasMany, belongsToMany } from '@tensei/common'

const postResource = resource('Post').fields([
    text('Title'),
    textarea('Content'),
    belongsTo('Category'),
    belongsToMany('Tag')
])

const categoryResource = resource('Category').fields([
    text('Name'),
    textarea('Description'),
    hasMany('Post')
])

const tagResource = resource('Tag').fields([
    text('Name'),
    textarea('Description'),
    belongsToMany('Post')
])

In a plugin, we can register custom resources, hooks, routes, graphql queries and so much more. We do this by calling the .register() method on a plugin and passing a callback. When Tensei is starting the application, it'll register our plugin by calling this callback.

import { plugin } from '@tensei/common'

const blog = plugin('Blog Plugin')
    .register(({
        extendResources,
        extendRoutes,
        extendGraphQlTypeDefs,
        extendGraphQlQueries,
        extendCommands,
        extendEvents
    }) => {
        extendResources([postResource, tagResource, categoryResource])
    })

The register hook receives a list of helpful methods we can use to extend the Tensei core. Some of these are:

  • extendResources to register new resources
  • extendCommands to register new CLI commands to the application from a plugin
  • extendGraphQlTypeDefs to add custom type definitions to the graphql schema.
  • extendGraphQlQueries to add custom graphql queries (mutations) to the graphql schema.
  • extendRoutes to add custom REST API routes or just normal routes to the Express application.
  • extendEvents to add custom event listeners to the core

In our case, during registration, we are adding three new resources to the core.

Extending routes

From a plugin, you may register custom routes on the application using the extendRoutes register function. Let's add a route for fetching blog statistics. Our focus won't be the statistics itself, but rather the custom route registration:

import { plugin, route } from '@tensei/common'

// Define a route
const statsRoute = route('Fetch blog stats')
    .get()
    .path('/blog/stats')
    .handle(async (({ db }), response) => (response.json({
        totalPosts: await db.posts().count(),
        totalCategories: await db.categories().count(),
    })))

const blog = plugin('Blog Plugin')
    .register(({
        extendResources,
        extendRoutes,
    }) => {
        extendResources([postResource, tagResource, categoryResource])

        // Extend routes in the core
        extendRoutes([statsRoute])
    })

Extending graphql queries

From a plugin, you may extend the graphql schema by adding custom type definitions and queries or mutations. Let's add a query to fetch statistics about the blog. First, we'll add a type definition, then we'll add a query:

import { plugin, graphQlQuery } from '@tensei/common'

const typeDefs = /* GraphQL */ `
    type BlogStatistics {
        totalPosts: Int!
        totalCategories: Int!
    }

    extend type Query {
        blogStats: BlogStatistics
    }
`

const statsQuery = graphQlQuery('Get blog statistics')
    .query()
    .path('blogStats')
    .handle((source, args, { db }) => ({
        totalPosts: () => db.posts().count(),
        totalCategories: () => db.categories().count(),
    }))

export const blog = plugin('Blog Plugin')
    .register(({
        extendResources,
        extendRoutes,
        extendGraphQlTypeDefs,
        extendGraphQlQueries
    }) => {
        // Extend resources in the core
        extendResources([postResource, tagResource, categoryResource])

        // Extend routes in the core
        extendRoutes([statsRoute])

        // Extend graphql types
        extendGraphQlTypeDefs([typeDefs])

        // Extend graphql queries
        extendGraphQlQueries([statsQuery])
    })

We used the extendGraphQlTypeDefs and extendGraphQlQueries methods to add types and queries to the graphql schema.

Extending event listeners

Let's say we wanted to subscribe to events from another plugin. As an example, let's say we wanted to send an email if a user registered a new account. User registration is handled by the auth plugin, and after a successful registration, the user::registered event is emitted. With custom event listeners, we can subscribe to this event and perform an action when this event is triggered. First we'll create a new event listener, and then we'll register it:

import { event } from '@tensei/common'

const sendStatsEmailAfterRegistration = event('user::registered')
    .listen(async ({ payload, ctx }) => {
        const stats = {
            totalPosts: await ctx.db.posts().count(),
            totalCategories: await ctx.db.categories().count(),
        }

        ctx.mailer.send(
            (message) => {
                message
                .to(payload.email)
                .html(`The latest stats are: Total posts: ${stats.totalPosts}, Total categories: ${stats.totalCategories}`)
            }
        )
    })


const blog = plugin('Blog Plugin')
    .register(({
        extendEvents,
    }) => {
        extendEvents([sendStatsEmailAfterRegistration])
    })

Seed the database

After the database connection is established and all plugins have been registered, we want to add one new category to the blog. We may do this in the boot hook. This hook is executed by the Tensei core during its life cycle. The .boot(). We also have access to the ORM and can use it to perform database queries. Let's add it here:

import { plugin } from '@tensei/common'

const blog = plugin('Blog Plugin')
    .register(({
        extendResources,
        extendRoutes,
    }) => {
        extendResources([postResource, tagResource, categoryResource])
        extendRoutes([statsRoute])
        extendGraphQlTypeDefs([typeDefs])
        extendGraphQlQueries([statsQuery])
    })
    .boot(async ({ db }) => {
        // Attempt to find a category with the name of General from the database.
        const generalCategory = await db.categories().findOne({ name: 'General' })

        // Create the General category if it does not yet exist in the database.
        if (! generalCategory) {
            // Create a new category instance
            const newCategory = db.categories().create({
                name: 'General'
            })

            // Persist the new category to the database.
            await db.categories().persistAndFlush(newCategory)
        }
    })

In the above snippet, we attempt to find a General category in the database, and if it doesn't exist, we create it. Remember that this is only executed during the Tensei boot process. This would not be executed when your application is receiving requests.