Here goes a simple tutorial to show you how you can build a NodeJS server with an API similar to the Express one. Just reminding the Express here is only to get the ideia of this tutorial, you can make APIs like Fastify, KOA or create a complete custom one.
First of all, I'll be using typescript
and esmodule
in this tutorial and will not cover some of basics about the creation of a server like the http
module of NodeJS and about the parsing of URL parameters. So I recommend you to see my tutorials about this topics: Servers with Node.js HTTP Module and How to build a URL parameters parser.
Collecting data
Let’s start by getting some values from the request. We’ll first need:
- Request method
- Pathname
- Query Params
For this initial step, we’ll need only this, after it we’ll see about path params and body.
import http from 'http'
const server = http.createServer((req, res) => {
const { method, url } = req
const { pathname, searchParams } = new URL(`http://any-host.io${url}`)
const queryParams = Object.fromEntries(searchParams.entries())
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({
method,
pathname,
queryParams: searchParams
}))
})
server.listen(3500, () => console.log('Server is running at http://localhost:3500'))
Notice that we instantiate an URL
object with a http://any-host.io
string and concatenate it with the url
constant, and then catch the path name and search params from it. This string concatenation is necessary because the URL class expects a valid url string as parameter and the url constant is only one part of it. The pathname
is in the url
the we destructured, but the url
comes with the search params together and we need them separated.
The searchParams
is an instance of URLSearchParams
, so we use the entries
method to get an array of arrays containing the values and then used the Object.fromEntries
to transform it into a normal object.
If you run the app and access localhost you will see a json string similiar to this one.
{ "method": "GET", "pathname": "/", "queryParams": {} }
Getting body data
In post, put, patch requests for example, we need the content of the incoming request body. For doing this we have some approaches and I’ll show two of them. The first, we need to use some of the request object events.
import http from 'http'
const server = http.createServer((req, res) => {
const { method, url } = req
const { pathname, searchParams } = new URL(`http://any-host.io${url}`)
const queryParams = Object.fromEntries(searchParams.entries())
const requestData = []
req.on('data', chunk => requestData.push(chunk))
req.on('end', () => {
const bodyString = Buffer.concat(requestData).toString()
const body = JSON.parse(bodyString)
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({
method,
pathname,
queryParams,
body
}))
})
})
server.listen(3500, () => console.log('Server is running at http://localhost:3500'))
Notice that we use an auxiliar variable called requestData
to store the pieces of the body as it comes, this data comes as a buffer, and when the request finishes the data sending we just need to concatenate it and convert to string. This is string can have many different forms and we can use the content-type
header, to know what you need to do to convert it. For now lets just parse it as JSON.
The second, is a much simpler way, but it can be hard to understand if you are not familiar with async iterators
, and it uses the same auxiliar variable. Normally this auxiliar variable will only contain one value, it will be more necessary when the request incoming data is too large.
import http from 'http'
const server = http.createServer(async (req, res) => {
const { method, url } = req
const { pathname, searchParams } = new URL(`http://any-host.io${url}`)
const queryParams = Object.fromEntries(searchParams.entries())
const requestData = []
for await (const data of req) {
requestData.push(data)
}
const bodyString = Buffer.concat(requestData).toString()
const body = JSON.parse(bodyString)
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({
method,
pathname,
queryParams,
body
}))
})
server.listen(3500, () => console.log('Server is running at http://localhost:3500'))
You can choose which of those ways you like to use to get the request data. In both cases, I would like to create a separate function to do the job. In this separate file we can even check for the length of the requestData array, because in requests of GET
method for example, there is no body in request.
// With request object events
function getRequestData(request: IncomingMessage) {
return new Promise((resolve, reject) => {
const requestData = []
request
.on('error', reject)
.on('data', chunk => requestData.push(chunk))
.on('end', () => {
if (!requestData.length) return resolve({})
const body = Buffer.concat(requestData).toString()
resolve(JSON.parse(body))
})
})
}
// With async iterators
function getRequestData(request: IncomingMessage) {
return new Promise(async (resolve, reject) => {
try {
const requestData = []
for await (const data of request) {
requestData.push(data)
}
if (!requestData.length) return resolve({})
const body = Buffer.concat(requestData).toString()
resolve(JSON.parse(body))
} catch(error) {
reject(error)
}
})
}
You can separate this in files too, it will be up to you to choose the way you prefer. I did it like this.
// get-request-data.ts
import { IncomingMessage } from 'http'
function getRequestData(request: IncomingMessage) {
return new Promise(async (resolve, reject) => {
try {
const requestData = []
for await (const data of request) {
requestData.push(data)
}
if (!requestData.length) return resolve({})
const body = Buffer.concat(requestData).toString()
resolve(JSON.parse(body))
} catch(error) {
reject(error)
}
})
}
// server.ts
import http from 'http'
import { getRequestData } from './get-request-data.js'
const server = http.createServer(async (req, res) => {
const { method, url } = req
const { pathname, searchParams } = new URL(`http://any-host.io${url}`)
const queryParams = Object.fromEntries(searchParams.entries())
const body = await getRequestData(req)
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({
method,
pathname,
queryParams,
body
}))
})
server.listen(3500, () => console.log('Server is running at http://localhost:3500'))
Router
With the data we need in hands, now its time to create our Router
. This Router class is very simple and in this point we’ll need some features implemented in the How to build a URL parameters parser tutorial.
First we need to export the routes
constant and RouteHandler
type from the file you put the url parameters parser code, I put it in a file called find-path-match.ts
.
The Router code is simple like this. Just to not confuse, I rename the routes
constant to routesList
.
import { RouteHandler, routesList } from './find-path-match.js'
export class Router {
get = this.#generateRouteRegisterFor('get')
post = this.#generateRouteRegisterFor('post')
put = this.#generateRouteRegisterFor('put')
delete = this.#generateRouteRegisterFor('delete')
#generateRouteRegisterFor(method: string) {
return (path: string, routeHandler: RouteHandler) => {
routesList[`${method}::${path}`] = routeHandler
return this
}
}
}
You can notice 2 things in this implementation, one is that all four methods are very similar and that all of them returns this
. The returning of this
is basically useful to chain method calls, like this:
router.get().post().put()
And about the implementation you can do something like this:
type IRouter = Record<
'get'| 'post'| 'put'| 'delete',
(path: string, routeHandler: RouteHandler) => IRouter
>
export function Router(): IRouter {
const methods = ['get', 'post', 'put', 'delete'] as const
const router = <IRouter> {}
methods.forEach(method => {
function routerFunction(path: string, routeHandler: RouteHandler) {
routesList[`${method}::${path}`] = routeHandler
return this
}
Object.assign(router, { [method]: routerFunction })
})
return router;
}
There is other way make this Router function, using reduce
for example, but I chose that one to be more simpler. Although the way using a class seems more repetitive or verbose, I like it, because it is more explicit and easier to understand, but it up to you to choose.
Join everything
Now we need to export the findPathMatch
function from the find-path-match.ts
file, and use it in our server implementation in server.ts
.
import http from 'http'
import { getRequestData } from './get-request-data.js'
import { findPathMatch } from './find-path-match.js'
const server = http.createServer(async (req, res) => {
const { method, url } = req
const { pathname, searchParams } = new URL(`http://any-host.io${url}`)
const queryParams = Object.fromEntries(searchParams.entries())
const body = await getRequestData(req)
const { handler, params } = findPathMatch(method, pathname)
if (handler) {
const request = {
headers: req.headers,
params,
queryParams,
body
}
return handler(request, res)
}
res.writeHead(404, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({
error: 'Resource not found'
}))
})
server.listen(3500, () => console.log('Server is running at http://localhost:3500'))
The handler
respects the RouteHandler
type that we made in the URL parameters parser and its value in the tutorial is (params: Record<string, string>) => void
and I changed it to:
interface RouteHandlerRequest {
headers: Record<string, unknown>
queryParams: Record<string, string>
params: Record<string, string>
body: any
}
type RouteHandler = (request: RouteHandlerRequest, response: ServerResponse) => void
With it done prepare the request value and pass it with the response object to the handler. If there is no match for the current route it resolve the request with a not found response.
Now its time to register some routes to test it.
// routes.js
import { Router } from './router.js'
const inMemoryData = []
const router = new Router()
router
.get('/find-all', (req, res) => {
res.end(JSON.stringify(inMemoryData))
})
.post('/create', (req, res) => {
inMemoryData.push(req.body)
res.statusCode = 204
res.end()
})
.delete('/:id', (req, res) => {
const index = inMemoryData.findIndex(item => item.id === req.params.id)
if (index !== -1) {
inMemoryData.splice(index, 1)
}
res.statusCode = 204
res.end()
})
With this code we can test some of the features we created, fell free to change and test it. Just don’t forget, you need to import this file in server.ts
.
import http from 'http'
import { getRequestData } from './get-request-data.js'
import { findPathMatch } from './find-path-match.js'
import './routes.js'
const server = http.createServer(async (req, res) => {
...
...
And that’s it, your server should be working fine.
Conclusion
I hope you could understand everything, in a overview it’s not so complex the implementation, and obviously there is much more things that Express do, but its too much to cover all here. Any question leave a comment and thanks for reading!!!
Top comments (0)