microfleet/core

View on GitHub
packages/plugin-router/src/router.ts

Summary

Maintainability
B
5 hrs
Test Coverage
import assert from 'node:assert/strict'
import { Tags } from 'opentracing'
import hyperid from 'hyperid'
import { Tracer } from 'opentracing'
import { Logger } from '@microfleet/plugin-logger'
import { glob } from 'glob'

import RequestCountTracker from './tracker'
import Routes from './routes'
import { Lifecycle } from './lifecycle'
import { ServiceAction, ServiceRequest } from './types/router'
import { RouterPluginRoutesConfig } from './types/plugin'
import {
  readRoutes,
  createServiceAction,
  requireServiceActionHandler,
} from './utils'

const { COMPONENT, ERROR } = Tags

export type RouterOptions = {
  lifecycle: Lifecycle
  routes: Routes
  requestCountTracker: RequestCountTracker
  log: Logger
  config?: RouterConfig
  tracer?: Tracer
}

export type RouterConfig = RouterPluginRoutesConfig

const finishSpan = ({ span }: ServiceRequest) => () => {
  if (span != null) {
    span.finish()
  }
}
const spanLog = (request: ServiceRequest, error: Error) => {
  if (request.span != null) {
    request.span.setTag(ERROR, true)
    request.span.log({
      'error.object': error,
      event: 'error',
      message: error.message,
      stack: error.stack,
    })
  }

  throw error
}

// Constants with possilble transport values
// @TODO is it possible to config it from own transport plugin
export const ActionTransport = {
  amqp: 'amqp',
  http: 'http',
  internal: 'internal',
  socketio: 'socketio',
} as const

  // todo to validation
  // based on this we validate input data
export const RequestDataKey = {
  amqp: 'params',
  delete: 'query',
  get: 'query',
  head: 'query',
  options: 'query',
  internal: 'params',
  patch: 'params',
  post: 'params',
  put: 'params',
  socketio: 'params',
} as const

export class Router {
  public readonly config?: RouterConfig
  public readonly routes: Routes
  public readonly requestCountTracker: RequestCountTracker

  public readonly lifecycle: Lifecycle
  protected readonly log: Logger
  protected readonly prefix?: string
  protected readonly tracer?: Tracer
  protected readonly idgen: hyperid.Instance
  protected readonly directory?: string
  protected readonly enabledGenericActions?: string[]

  constructor({ lifecycle, routes, config, requestCountTracker, log, tracer }: RouterOptions) {
    this.lifecycle = lifecycle
    this.routes = routes
    this.config = config
    this.requestCountTracker = requestCountTracker
    this.log = log
    this.tracer = tracer
    this.idgen = hyperid()

    if (config !== undefined) {
      const { directory, enabledGenericActions, prefix } = config

      if (prefix !== undefined && prefix.length > 0) {
        this.prefix = prefix
      }

      this.directory = directory
      this.enabledGenericActions = enabledGenericActions
    }
  }

  public async ready(): Promise<void> {
    if (this.directory !== undefined) {
      await this.loadActionsFromDirectory(this.directory)
    }

    if (this.enabledGenericActions !== undefined) {
      await this.loadGenericActions(this.enabledGenericActions)
    }
  }

  public prefixRoute(route: string): string {
    const { prefix } = this

    if (prefix !== undefined) {
      return `${prefix}.${route}`
    }

    return route
  }

  public addRoute(route: string, handler: ServiceAction): void {
    const { routes, config } = this
    let name: string = route

    if (config !== undefined) {
      if (config.disabled !== undefined && Object.keys(config.disabled).length > 0) {
        const disabledRoute = config.disabled[route]
        if (disabledRoute === route) {
          return
        }
      }

      // allows generic overwrites
      if (config.allRoutes) {
        Object.assign(handler, config.allRoutes)
      }

      if (config.enabled !== undefined && Object.keys(config.enabled).length > 0) {
        const updatedConfig = config.enabled[route]
        if (updatedConfig === undefined) {
          return
        }

        if (typeof updatedConfig === 'string') {
          name = updatedConfig
        } else {
          if (updatedConfig.config) Object.assign(handler, updatedConfig.config)
          if (updatedConfig.name) name = updatedConfig.name
        }
      }
    }

    const action = createServiceAction(route, handler)
    for (const transport of (action.transports || Object.keys(ActionTransport))) {
      routes.add(transport, this.prefixRoute(name), action)
    }
  }

  public async loadGenericActions(enabled: string[]): Promise<void> {
    const handlers = enabled.map(async (route) => {
      const serviceActions = await glob(`actions/${route}.{js,cjs,mjs,ts,cts,mts}`, {
        cwd: __dirname,
        absolute: true,
        ignore: ['*.d.ts', '**/*.d.ts', '*.d.mts', '**/*.d.mts', '*.d.cts', '**/*.d.cts']
      })

      assert(serviceActions.length > 0, `did not find ${route} action`)
      const [serviceAction] = serviceActions

      const handler = await requireServiceActionHandler(serviceAction)
      this.addRoute(`generic.${route}`, handler)
    })

    await Promise.all(handlers)
  }

  public async loadActionsFromDirectory(directory: string): Promise<void> {
    for (const [route, handler] of await readRoutes(directory)) {
      this.addRoute(route, handler)
    }
  }

  public async prefixAndDispatch(routeWithoutPrefix: string, request: ServiceRequest): Promise<any> {
    request.route = this.prefixRoute(routeWithoutPrefix)
    return this.dispatch(request)
  }

  public async dispatch(request: ServiceRequest): Promise<any> {
    assert(request.route)
    assert(request.transport)

    const { route, transport } = request
    const { tracer, log, lifecycle } = this

    // @todo extension?
    // if we have installed tracer - init span
    if (tracer !== undefined) {
      request.span = tracer.startSpan(`dispatch:${route}`, {
        childOf: request.parentSpan || undefined,
        tags: {
          [COMPONENT]: request.transport,
        },
      })
    }

    // "as ServiceAction" is not ok, because getAction() result could be undefined
    // but there is a addition check for it in lifecycle/handlers/request
    // otherwise will be a lot of redundant asserts in other lifecycle handlers
    // maybe you can fix it using a typescript magic
    request.action = this.routes.getAction(transport, route) as ServiceAction
    request.log = log.child({
      reqId: this.idgen(),
    })

    try {
      await lifecycle.run(request)
    } catch (e: any) {
      spanLog(request, e)
    } finally {
      finishSpan(request)
    }

    return request.response
  }
}

export default Router