/frourio

Fast and type-safe full stack framework, for TypeScript

Primary LanguageTypeScriptMIT LicenseMIT


frourio

npm version npm download Node.js CI Codecov Language grade: JavaScript

Fast and type-safe full stack framework, for TypeScript




Why frourio ?

Even if you write both the frontend and backend in TypeScript, you can't statically type-check the API's sparsity.

We are always forced to write "Two TypeScript".
We waste a lot of time on dynamic testing using the browser and server.

Why frourio ?


Frourio is a framework for developing web apps quickly and safely in "One TypeScript".

Architecture of create-frourio-app


Documents

https://frourio.io/docs

Table of Contents

Install

Make sure you have npx installed (npx is shipped by default since npm 5.2.0)

$ npx create-frourio-app

Or starting with npm v6.1 you can do:

$ npm init frourio-app

Or with yarn:

$ yarn create frourio-app

Controller

Case 1 - Define GET: /tasks?limit={number}

server/types/index.ts

export type Task = {
  id: number
  label: string
  done: boolean
}

server/api/tasks/index.ts

import { Task } from '$/types' // path alias $ -> server

export type Methods = {
  get: {
    query: {
      limit: number
    }

    resBody: Task[]
  }
}

server/api/tasks/controller.ts

import { defineController } from './$relay' // '$relay.ts' is automatically generated by frourio
import { getTasks } from '$/service/tasks'

export default defineController(() => ({
  get: async ({ query }) => ({
    status: 200,
    body: (await getTasks()).slice(0, query.limit)
  })
}))

Case 2 - Define POST: /tasks

server/api/tasks/index.ts

import { Task } from '$/types' // path alias $ -> server

export type Methods = {
  post: {
    reqBody: Pick<Task, 'label'>
    status: 201
    resBody: Task
  }
}

server/api/tasks/controller.ts

import { defineController } from './$relay' // '$relay.ts' is automatically generated by frourio
import { createTask } from '$/service/tasks'

export default defineController(() => ({
  post: async ({ body }) => {
    const task = await createTask(body.label)

    return { status: 201, body: task }
  }
}))

Case 3 - Define GET: /tasks/{taskId}

server/api/tasks/_taskId@number/index.ts

import { Task } from '$/types' // path alias $ -> server

export type Methods = {
  get: {
    resBody: Task
  }
}

server/api/tasks/_taskId@number/controller.ts

import { defineController } from './$relay' // '$relay.ts' is automatically generated by frourio
import { findTask } from '$/service/tasks'

export default defineController(() => ({
  get: async ({ params }) => {
    const task = await findTask(params.taskId)

    return task ? { status: 200, body: task } : { status: 404 }
  }
}))

Hooks

Frourio can use hooks of Fastify.
There are four types of hooks, onRequest / preParsing / preValidation / preHandler.

Lifecycle

Incoming Request
  │
  └─▶ Routing
        │
  404 ◀─┴─▶ onRequest Hook
              │
    4**/5** ◀─┴─▶ preParsing Hook
                    │
          4**/5** ◀─┴─▶ Parsing
                          │
                4**/5** ◀─┴─▶ preValidation Hook
                                │
                      4**/5** ◀─┴─▶ Validation
                                      │
                                400 ◀─┴─▶ preHandler Hook
                                            │
                                  4**/5** ◀─┴─▶ User Handler
                                                  │
                                        4**/5** ◀─┴─▶ Outgoing Response

Directory level hooks

Directory level hooks are called at the current and subordinate endpoints.

server/api/tasks/hooks.ts

import { defineHooks } from './$relay' // '$relay.ts' is automatically generated by frourio

export default defineHooks(() => ({
  onRequest: [
    (req, reply, done) => {
      console.log('Directory level onRequest first hook:', req.url)
      done()
    },
    (req, reply, done) => {
      console.log('Directory level onRequest second hook:', req.url)
      done()
    }
  ],
  preParsing: (req, reply, payload, done) => {
    console.log('Directory level preParsing single hook:', req.url)
    done()
  }
}))

Controller level hooks

Controller level hooks are called at the current endpoint after directory level hooks.

server/api/tasks/controller.ts

import { defineHooks, defineController } from './$relay' // '$relay.ts' is automatically generated by frourio
import { getTasks, createTask } from '$/service/tasks'

export const hooks = defineHooks(() => ({
  onRequest: (req, reply, done) => {
    console.log('Controller level onRequest single hook:', req.url)
    done()
  },
  preParsing: [
    (req, reply, payload, done) => {
      console.log('Controller level preParsing first hook:', req.url)
      done()
    },
    (req, reply, payload, done) => {
      console.log('Controller level preParsing second hook:', req.url)
      done()
    }
  ]
}))

export default defineController(() => ({
  get: async ({ query }) => ({
    status: 200,
    body: (await getTasks()).slice(0, query.limit)
  }),
  post: async ({ body }) => {
    const task = await createTask(body.label)

    return { status: 201, body: task }
  }
}))

Validation

Query, reqHeaders and reqBody are validated by specifying Class with class-validator.
The class needs to be exported from server/validators/index.ts.

server/validators/index.ts

import { MinLength, IsString } from 'class-validator'

export class LoginBody {
  @MinLength(5)
  id: string

  @MinLength(8)
  pass: string
}

export class TokenHeader {
  @IsString()
  @MinLength(10)
  token: string
}

server/api/token/index.ts

import { LoginBody, TokenHeader } from '$/validators'

export type Methods = {
  post: {
    reqBody: LoginBody
    resBody: {
      token: string
    }
  }

  delete: {
    reqHeaders: TokenHeader
  }
}
$ curl -X POST -H "Content-Type: application/json" -d '{"id":"correctId","pass":"correctPass"}' http://localhost:8080/api/token
{"token":"XXXXXXXXXX"}

$ curl -X POST -H "Content-Type: application/json" -d '{"id":"abc","pass":"12345"}' http://localhost:8080/api/token -i
HTTP/1.1 400 Bad Request

$ curl -X POST -H "Content-Type: application/json" -d '{"id":"incorrectId","pass":"incorrectPass"}' http://localhost:8080/api/token -i
HTTP/1.1 401 Unauthorized

Deployment

Frourio is complete in one directory, but not monolithic.
Client and server are just statically connected by a type and are separate projects.
So they can be deployed in different environments.

Client

$ npm run build:client
$ npm run start:client

Server

$ npm run build:server
$ npm run start:server

or

$ cd server
$ npm run build
$ npm run start

Dependency Injection

Frourio use frouriojs/velona for dependency injection.

server/api/tasks/index.ts

import { Task } from '$/types'

export type Methods = {
  get: {
    query?: {
      limit?: number
      message?: string
    }

    resBody: Task[]
  }
}

server/service/tasks.ts

import { PrismaClient } from '@prisma/client'
import { depend } from 'velona' // dependency of frourio
import { Task } from '$/types'

const prisma = new PrismaClient()

export const getTasks = depend(
  { prisma: prisma as { task: { findMany(): Promise<Task[]> } } }, // inject prisma
  async ({ prisma }, limit?: number) => // prisma is injected object
    (await prisma.task.findMany()).slice(0, limit)
)

server/api/tasks/controller.ts

import { defineController } from './$relay'
import { getTasks } from '$/service/tasks'

const print = (text: string) => console.log(text)

export default defineController(
  { getTasks, print }, // inject functions
  ({ getTasks, print }) => ({ // getTasks and print are injected function
    get: async ({ query }) => {
      if (query?.message) print(query.message)

      return { status: 200, body: await getTasks(query?.limit) }
    }
  })
)

server/test/server.test.ts

import fastify from 'fastify'
import controller from '$/api/tasks/controller'

test('dependency injection into controller', async () => {
  let printedMessage = ''

  const injectedController = controller.inject((deps) => ({
    getTasks: deps.getTasks.inject({
      prisma: {
        task: {
          findMany: () =>
            Promise.resolve([
              { id: 0, label: 'task1', done: false },
              { id: 1, label: 'task2', done: false },
              { id: 2, label: 'task3', done: true },
              { id: 3, label: 'task4', done: true },
              { id: 4, label: 'task5', done: false }
            ])
        }
      }
    }),
    print: (text: string) => {
      printedMessage = text
    }
  }))(fastify())

  const limit = 3
  const message = 'test message'
  const res = await injectedController.get({
    query: { limit, message }
  })

  expect(res.body).toHaveLength(limit)
  expect(printedMessage).toBe(message)
})
$ npm test

PASS server/test/server.test.ts
  ✓ dependency injection into controller (4 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.67 s, estimated 8 s
Ran all test suites.

License

Frourio is licensed under a MIT License.