Frourio is a perfectly type-checkable REST framework for TypeScript.
Even if you write both the front and server 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 Docker.
Frourio is a framework for developing web apps quickly and safely in "One TypeScript".
Machine: Linux fv-az18 5.4.0-1026-azure #26~18.04.1-Ubuntu SMP Thu Sep 10 16:19:25 UTC 2020 x86_64 x86_64 x86_64 GNU/Linux | 2 vCPUs | 7GB.
Method: autocannon -c 100 -d 40 -p 10 localhost:3000
(two rounds; one to warm-up, one to measure).
Framework | Version | Requests/sec | Latency |
---|---|---|---|
fastify | 3.5.1 | 38,018 | 2.54 |
frourio | 0.17.0 | 36,815 | 2.62 |
nest-fastify | 7.4.4 | 31,960 | 3.04 |
micro | 9.3.4 | 29,672 | 3.28 |
express | 4.17.1 | 8,239 | 11.98 |
nest | 7.4.4 | 7,311 | 13.54 |
frourio-express | 0.17.0 | 7,235 | 13.67 |
Benchmarks taken using https://github.com/frouriojs/benchmarks. This is a synthetic, "hello world" benchmark that aims to evaluate the framework overhead.
- Install
- Express.js mode
- Environment
- Entrypoint
- Controller
- Hooks
- Validation
- Error handling
- FormData
- O/R mapping tool
- CORS / Helmet
- Dependency Injection
Make sure you have npx installed (npx
is shipped by default since npm 5.2.0
)
$ npx create-frourio-app <my-project>
Or starting with npm v6.1 you can do:
$ npm init frourio-app <my-project>
Or with yarn:
$ yarn create frourio-app <my-project>
Frourio uses fastify.js as its HTTP server.
If you choose express.js in create-frourio-app, please refer to the following repositories.
GitHub: frourio-express
Note: frourio is 5x faster than frourio-express
Frourio requires TypeScript 3.9 or higher.
If the TypeScript version of VSCode is low, an error is displayed during development.
server/index.ts
import Fastify from 'fastify'
import server from './$server' // '$server.ts' is automatically generated by frourio
const fastify = Fastify()
server(fastify, { basePath: '/api/v1' })
fastify.listen(3000)
$ npm run dev
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)
})
}))
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 }
}
}))
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 }
}
}))
Frourio can use hooks of fastify.js.
There are four types of hooks, onRequest / preParsing / preValidation / preHandler.
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 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 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 }
}
}))
$ cd server
$ npm install fastify-auth
$ cd server
$ yarn add fastify-auth
server/index.ts
import Fastify from 'fastify'
import fastifyAuth from 'fastify-auth'
import server from './$server' // '$server.ts' is automatically generated by frourio
const fastify = Fastify()
fastify.register(fastifyAuth).after(() => {
server(fastify, { basePath: '/api/v1' })
})
fastify.listen(3000)
server/api/user/hooks.ts
import { defineHooks } from './$relay' // '$relay.ts' is automatically generated by frourio
import { getUserIdByToken } from '$/service/user'
// Export the User in hooks.ts to receive the user in controller.ts
export type User = {
id: string
}
export default defineHooks((fastify) => ({
preHandler: fastify.auth([
(req, _, done) => {
const user =
typeof req.headers.token === 'string' &&
getUserIdByToken(req.headers.token)
if (user) {
// eslint-disable-next-line
// @ts-expect-error
req.user = user
done()
} else {
done(new Error('Unauthorized'))
}
}
])
}))
server/api/user/controller.ts
import { defineController } from './$relay'
import { getUserNameById } from '$/service/user'
export default defineController(() => ({
get: async ({ user }) => ({ status: 200, body: await getUserNameById(user.id) })
}))
Path parameter can be specified as string or number type after @
.
(Default is string | number
)
server/api/tasks/_taskId@number/index.ts
import { Task } from '$/types'
export type Methods = {
get: {
resBody: Task
}
}
server/api/tasks/_taskId@number/controller.ts
import { defineController } from './$relay'
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 }
}
}))
$ curl http://localhost:8080/api/tasks
[{"id":0,"label":"sample task","done":false}]
$ curl http://localhost:8080/api/tasks/0
{"id":0,"label":"sample task","done":false}
$ curl http://localhost:8080/api/tasks/1 -i
HTTP/1.1 404 Not Found
$ curl http://localhost:8080/api/tasks/abc -i
HTTP/1.1 400 Bad Request
Properties of number or number[] are automatically validated.
server/api/tasks/index.ts
import { Task } from '$/types'
export type Methods = {
get: {
query?: {
limit: number
}
resBody: Task[]
}
}
server/api/tasks/controller.ts
import { defineController } from './$relay'
import { getTasks } from '$/service/tasks'
export default defineController(() => ({
get: async ({ query }) => ({
status: 200,
body: (await getTasks()).slice(0, query?.limit)
})
}))
$ curl http://localhost:8080/api/tasks
[{"id":0,"label":"sample task 0","done":false},{"id":1,"label":"sample task 1","done":false},{"id":1,"label":"sample task 2","done":false}]
$ curl http://localhost:8080/api/tasks?limit=1
[{"id":0,"label":"sample task 0","done":false}]
$ curl http://localhost:8080/api/tasks?limit=abc -i
HTTP/1.1 400 Bad Request
If no reqFormat is specified, reqBody is parsed as application/json
.
server/api/tasks/index.ts
import { Task } from '$/types'
export type Methods = {
post: {
reqBody: Pick<Task, 'label'>
resBody: Task
}
}
server/api/tasks/controller.ts
import { defineController } from './$relay'
import { createTask } from '$/service/tasks'
export default defineController(() => ({
post: async ({ body }) => {
const task = await createTask(body.label)
return { status: 201, body: task }
}
}))
$ curl -X POST -H "Content-Type: application/json" -d '{"label":"sample task3"}' http://localhost:8080/api/tasks
{"id":3,"label":"sample task 3","done":false}
$ curl -X POST -H "Content-Type: application/json" -d '{Invalid JSON}' http://localhost:8080/api/tasks -i
HTTP/1.1 400 Bad Request
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
server/api/tasks/controller.ts
import { defineController } from './$relay'
import { createTask } from '$/service/tasks'
export default defineController(() => ({
post: async ({ body }) => {
try {
const task = await createTask(body.label)
return { status: 201, body: task }
} catch (e) {
return { status: 500, body: 'Something broke!' }
}
}
}))
https://github.com/fastify/fastify/blob/master/docs/Hooks.md#onerror
server/index.ts
import Fastify from 'fastify'
import server from './$server'
const fastify = Fastify()
server(fastify, { basePath: '/api/v1' })
fastify.addHook('onError', (req, reply, err) => {
console.error(err.stack)
})
fastify.listen(3000)
Frourio parses FormData automatically in fastify-multipart.
server/api/user/index.ts
export type Methods = {
post: {
reqFormat: FormData
reqBody: { icon: Blob }
status: 204
}
}
Properties of Blob or Blob[] type are converted to Multipart object.
server/api/user/controller.ts
import { defineController } from './$relay'
import { changeIcon } from '$/service/user'
export default defineController(() => ({
post: async ({ params, body }) => {
// body.icon is multer object
await changeIcon(params.userId, body.icon)
return { status: 204 }
}
}))
https://github.com/mscdex/busboy#busboy-methods
server/index.ts
import Fastify from 'fastify'
import server from './$server' // '$server.ts' is automatically generated by frourio
const fastify = Fastify()
server(fastify, { basePath: '/api/v1', multipart: { /* limit, ... */} })
fastify.listen(3000)
-
Selecting the DB when installing create-frourio-app
-
Start the DB
-
Call the development command
$ npm run dev
-
Create schema file
server/prisma/schema.prisma
datasource db { provider = "mysql" url = env("DATABASE_URL") } generator client { provider = "prisma-client-js" } model Task { id Int @id @default(autoincrement()) label String done Boolean @default(false) }
-
Call the migration command
$ npm run migrate
-
Migration is done to the DB
-
Selecting the DB when installing create-frourio-app
-
Start the DB
-
Call the development command
$ npm run dev
-
Create an Entity file
server/entity/Task.ts
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm' @Entity() export class Task { @PrimaryGeneratedColumn() id: number @Column({ length: 100 }) label: string @Column({ default: false }) done: boolean }
-
Call the migration command
$ npm run migration:generate
-
Migration is done to the DB
$ cd server
$ npm install fastify-cors fastify-helmet
server/index.ts
import Fastify from 'fastify'
import helmet from 'helmet'
import cors from 'fastify-cors'
import server from './$server'
const fastify = Fastify()
fastify.register(helmet)
fastify.register(cors)
server(fastify, { basePath: '/api/v1' })
fastify.listen(3000)
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 controller from '$/api/tasks/controller'
import { getTasks } from '$/service/tasks'
test('dependency injection into controller', async () => {
let printedMessage = ''
const injectedController = controller.inject({
getTasks: 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
}
})()
const limit = 3
const message = 'test message'
const res = await injectedController.get({
query: { limit, message },
body: undefined,
headers: undefined
})
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.
Frourio is licensed under a MIT License.