Tutorial: Bookstore API
This tutorial will guide you in building a small bookstore API with Axe API. Prior knowledge of Axe API is not required, as this tutorial provides a thorough explanation of how Axe API operates and why it is a potent tool.
- You will learn
- How to create a new project
- How to connect a database
- How to define daatabase migrations
- How to create endpoints
- How to validate request data
- How to use middlewares
- How to use hooks
- How to query data
Bookstore API
The Bookstore API is designed to showcase the fundamental features of Axe API. It includes three tables (users
, books
, and orders
) with corresponding endpoints.
The process of building the API will be demonstrated from scratch.
Step 1. Installing CLI
To create a new Axe API project, the axe-magic CLI can be utilized.
The tool can be installed on your device using the specified command.
$ npm i -g axe-magic
After the installation of axe-magic, the version of the tool can be verified;
$ axe-magic --version
Step 2. Creating a new project
The axe-magic CLI is a tool to create a new Axe API project by pulling a template from GitHub and configuring it.
To create a new project, you can use the given command.
$ axe-magic new bookstore
To install the dependencies for the bookstore project, you need to navigate to the project directory and run the command npm install
in your terminal.
$ cd bookstore
$ npm install
Step 3. Setup database
To use Axe API with a relational database system, a running database is required, and a database schema needs to be created to work on it.
Axe API supports many different relational database systems such as PostgreSQL, CockroachDB, MSSQL, MySQL, MariaDB, SQLite3, Better-SQLite3, Oracle, and Amazon Redshift.
While this tutorial covers MySQL and PostgreSQL examples, the equivalent commands can be used for other databases.
Let's create the bookstore
schema;
CREATE DATABASE bookstore
CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
CREATE DATABASE bookstore
ENCODING 'UTF8'
LC_COLLATE = 'en_US.UTF-8'
LC_CTYPE = 'en_US.UTF-8';
We should add MySQL or PostgreSQL library to dependencies to create a connection.
$ npm install mysql --save
$ npm install pg --save
Step 4. Setting configurations
To set up the database connection for Axe API, you need to create a .env
file in the root folder of the project and set the appropriate database connection information.
During initialization, Axe API loads this file and sets up the database connection based on the information provided in the file.
NODE_ENV=development
APP_PORT=3000
DB_CLIENT=mysql
DB_HOST=localhost
DB_USER=db-user
DB_PASSWORD=db-password
DB_DATABASE=bookstore
Step 5. Executing the applications
To execute the Axe API application, you can use the command npm run start:dev
in the terminal, and Axe API will start the server on the defined port in the .env
file.
$ npm run start:dev
When the API is running correctly, you should see the following messages in your console:
[18:00:00] INFO: Axe API listens requests on http://localhost:3000
This indicates that the Axe API server is listening on the specified URL. To check what your project has, you can visit localhost:3000/routes.
However, the response will be empty as your project does not currently have any model.
Step 6. Creating migrations
The next step is to create the database tables. Axe API uses the knex.js library for database operations and migrations.
Therefore, you should install the knex CLI on your machine.
$ npm install -g knex
To create a migration file for each table, you can execute the following command:
$ knex --esm migrate:make 1Users
$ knex --esm migrate:make 2Books
$ knex --esm migrate:make 3Orders
To define the structure of each table in a migration file, you can easily copy and paste the following content into each file.
export const up = function (knex) {
return knex.schema.createTable("users", function (table) {
table.increments();
table.string("email").notNullable().unique().index();
table.string("password").notNullable();
table.string("first_name").notNullable();
table.string("last_name").notNullable();
table.timestamps();
});
};
export const down = function (knex) {
return knex.schema.dropTable("users");
};
export const up = function (knex) {
return knex.schema.createTable("books", function (table) {
table.increments();
table.string("name").notNullable();
table.string("author").notNullable();
table.double("price").notNullable();
table.timestamps();
});
};
export const down = function (knex) {
return knex.schema.dropTable("books");
};
export const up = function (knex) {
return knex.schema.createTable("orders", function (table) {
table.increments();
table.integer("book_id").unsigned().notNullable();
table.integer("user_id").unsigned().notNullable();
table.integer("quantity").notNullable().defaultTo(1);
table.timestamps();
table
.foreign("book_id")
.references("books.id")
.onDelete("CASCADE")
.onUpdate("CASCADE");
table
.foreign("user_id")
.references("users.id")
.onDelete("CASCADE")
.onUpdate("CASCADE");
});
};
export const down = function (knex) {
return knex.schema.dropTable("orders");
};
Once your migration files are ready, you can use the following command to migrate your database.
$ knex --esm migrate:latest
Let's break down the next steps after connecting to the database and creating tables. These steps are fairly common for other frameworks or libraries as well.
Step 7. Setting up models
The next task is to set up models, which are located in the Models
folder under the app
directory.
In Axe API, you can have multiple versions of your API on the same database schema, which is why you'll find the app/v1
folder in your project.
Let's create model files for all tables;
import { Model } from "axe-api";
class User extends Model {}
export default User;
import { Model } from "axe-api";
class Book extends Model {}
export default Book;
import { Model } from "axe-api";
class Order extends Model {}
export default Order;
After you created models files, you should be able to see the following results when you visit localhost:3000/routes URL.
[
"POST /api/v1/books",
"GET /api/v1/books",
"GET /api/v1/books/:id",
"PUT /api/v1/books/:id",
"PATCH /api/v1/books/:id",
"DELETE /api/v1/books/:id",
"POST /api/v1/orders",
"GET /api/v1/orders",
"GET /api/v1/orders/:id",
"PUT /api/v1/orders/:id",
"PATCH /api/v1/orders/:id",
"DELETE /api/v1/orders/:id",
"POST /api/v1/users",
"GET /api/v1/users",
"GET /api/v1/users/:id",
"PUT /api/v1/users/:id",
"PATCH /api/v1/users/:id",
"DELETE /api/v1/users/:id"
]
You can see the following pagination result when you visit the localhost:3000/api/v1/users;
{
"data": [],
"pagination": {
"total": 0,
"lastPage": 0,
"perPage": 10,
"currentPage": 1,
"from": 0,
"to": 0
}
}
This result indicates that your models have been analyzed correctly by Axe API and that all endpoints have been added to your API.
Until now, we only created the basic structure of your API but we are going to add more logic in the following steps.
Step 8. Adding new data
The models are currently only set up for fetching data and not accepting inserted data for security reasons. Developers must define which fields can be filled by clients and specify data validation rules.
The next step is adding the fillable
and the validation
getters.
import { Model } from "axe-api";
class User extends Model {
get fillable() {
return ["email", "first_name", "last_name", "password"];
}
get validations() {
return {
email: "required|min:3|max:255|email",
first_name: "required|min:2|max:50",
last_name: "required|min:2|max:50",
password: "required|min:6|max:100",
};
}
}
export default User;
import { Model } from "axe-api";
class Book extends Model {
get fillable() {
return ["name", "author", "price"];
}
get validations() {
return {
name: "required|min:3|max:255",
author: "required|min:2|max:50",
price: "required|numeric",
};
}
}
export default Book;
import { Model } from "axe-api";
class Order extends Model {
get fillable() {
return ["book_id", "user_id", "quantity"];
}
get validations() {
return {
book_id: "required|numeric",
user_id: "required|numeric",
quantity: "required|numeric",
};
}
}
export default Order;
By using these definitions, we essentially inform Axe API of which fields can be filled and specify the data validation rules to be applied.
Let's try to create a new user without data first;
$ curl \
-H "Content-Type: application/json" \
-X POST http://localhost:3000/api/v1/users
{
"errors": {
"email": ["The email field is required."],
"first_name": ["The first name field is required."],
"last_name": ["The last name field is required."],
"password": ["The password field is required."]
}
}
You should be able to see the following error message after the cURL request. This is a validation error that uses by Axe API.
Let's create an acceptable user by sending the following cURL request. If everything goes fine, you should be able to see created user record as an HTTP response.
$ curl \
-d '{"email": "karl@axe-api.com", "first_name": "Karl", "last_name":"Popper", "password": "my-secret-password"}' \
-H "Content-Type: application/json" \
-X POST http://localhost:3000/api/v1/users
{
"id": 1,
"email": "karl@axe-api.com",
"password": "my-secret-password",
"first_name": "Karl",
"last_name": "Popper",
"created_at": "2023-04-16T11:31:44.000Z",
"updated_at": "2023-04-16T11:31:44.000Z"
}
Since we already have a valid user record, let's create a book
and an order
, to use them in the following section of the tutorial.
$ curl \
-d '{"name": "How to build a Rest API?", "author": "Axe API", "price": 50}' \
-H "Content-Type: application/json" \
-X POST http://localhost:3000/api/v1/books
$ curl \
-d '{"user_id": 1, "book_id": 1, "quantity": 1}' \
-H "Content-Type: application/json" \
-X POST http://localhost:3000/api/v1/orders
Now we have a user, a book, and an order record on the database.
Let's move to the next chapter.
Step 9. Creating relations
Axe API has strong abilities to understand the relationship between models. In this section, we are going to define relationships between models and see how we can use them in queries.
Let's define the relations of the Order
model.
import { Model } from "axe-api";
class Order extends Model {
get fillable() {
return ["book_id", "user_id", "quantity"];
}
get validations() {
return {
book_id: "required|numeric",
user_id: "required|numeric",
quantity: "required|numeric",
};
}
user() {
return this.hasOne("User", "id", "user_id");
}
book() {
return this.hasOne("Book", "id", "book_id");
}
}
export default Order;
In this definition, we tell Axe API that the Order
model has a one-to-one relationship with the User
and Book
models. Axe API is such a powerful tool that can use this information in queries.
Let's try to paginate orders, first. The response you can get would be like the following JSON;
$ curl \
-H "Content-Type: application/json" \
-X GET http://localhost:3000/api/v1/orders
{
"data": [
{
"id": 1,
"book_id": 1,
"user_id": 1,
"quantity": 1,
"created_at": "2023-04-16T11:37:08.000Z",
"updated_at": "2023-04-16T11:37:08.000Z"
}
],
"pagination": {
"total": 1,
"lastPage": 1,
"perPage": 10,
"currentPage": 1,
"from": 0,
"to": 1
}
}
But Axe API provides a with
parameter to clients, to be able to get related data. Let's try to get orders with user
and book
data with the following request.
$ curl \
-H "Content-Type: application/json" \
-X GET "http://localhost:3000/api/v1/orders?with=user,book" // [!code focus]
{
"data": [
{
"id": 1,
"book_id": 1,
"user_id": 1,
"quantity": 1,
"created_at": "2023-04-16T11:37:08.000Z",
"updated_at": "2023-04-16T11:37:08.000Z",
"user": {
"id": 1,
"email": "karl@axe-api.com",
"password": "my-secret-password",
"first_name": "Karl",
"last_name": "Popper",
"created_at": "2023-04-16T11:31:44.000Z",
"updated_at": "2023-04-16T11:31:44.000Z"
},
"book": {
"id": 1,
"name": "How to build a Rest API?",
"author": "Axe API",
"price": 50,
"created_at": "2023-04-16T11:36:21.000Z",
"updated_at": "2023-04-16T11:36:21.000Z"
}
}
],
"pagination": {
"total": 1,
"lastPage": 1,
"perPage": 10,
"currentPage": 1,
"from": 0,
"to": 1
}
}
As you can see in the response above, related user and book data have been added to the order response.
We only defined models and their relations. In return, we got so strong API that we can query the data by using relations.
Unlike everybody who says that Rest APIs have under-fetching issues, Axe APIs don't have that problem. HTTP clients can get whatever they want whenever they wish from Axe API projects.
Step 10. Hiding sensitive data
As you can notice, in the previous section Axe API returned the user's password. Of course, this is such unacceptable behavior.
To prevent this issue, Axe API provides many different solutions. The easiest way is using the hiddens
getter.
import { Model } from "axe-api";
class User extends Model {
get fillable() {
return ["email", "first_name", "last_name", "password"];
}
get validations() {
return {
email: "required|min:3|max:255|email",
first_name: "required|min:2|max:50",
last_name: "required|min:2|max:50",
password: "required|min:6|max:100",
};
}
get hiddens() {
return ["password"];
}
}
export default User;
After this getter, HTTP clients are not able to see the password
field in the results.
Let's try the following cURL request.
$ curl \
-H "Content-Type: application/json" \
-X GET "http://localhost:3000/api/v1/users/1"
{
"id": 1,
"email": "karl@axe-api.com",
"first_name": "Karl",
"last_name": "Popper",
"created_at": "2023-04-16T11:31:44.000Z",
"updated_at": "2023-04-16T11:31:44.000Z"
}
Step 11. Adding hooks
Axe API analyzes your models, creates routes, and handles HTTP requests. But the real world is not simple like that.
Until now we've only seen the Axe API magics. But as developers, we should be able to add our custom logic to the APIs. For example; we should be able to hash the user's password, send a welcome email to the users, check some other things, etc.
Axe API supports a strong hook and event mechanism that you can add your custom logic to every HTTP request.
As an example, let's try to hash the user's password in the POST request. First, we need to install some dependencies;
$ npm i --save bcrypt
$ npm i --save-dev @types/bcrypt
After that, the only thing to do is add the following hook to your project;
import bcrypt from "bcrypt";
import { IContext } from "axe-api";
export default async ({ formData }: IContext) => {
formData.password = bcrypt.hashSync(formData.password, 10);
};
If you look at the path of the file, it clearly describes the hook file is related to User
model. Also, by the name of the file, this function will be executed before inserting a new user.
Let's send the following cURL request again to see the results.
$ curl \
-d '{"email": "locke@axe-api.com", "first_name": "John", "last_name":"Locke", "password": "my-secret-password"}' \
-H "Content-Type: application/json" \
-X POST http://localhost:3000/api/v1/users
Since we don't allow to return of the password via API response, you can check the hashed value on the database.
SELECT * FROM users;
SELECT * FROM users;
id | password | |
---|---|---|
1 | karl@axe-api.com | my-secret-password |
2 | locke@axe-api.com | $2b$10$IyIxdf$IyIxdf... |
Step 12. Querying data
Once you design your models, Axe API provides powerful query options.
For example; you can decide which fields
should be listed.
$ curl \
-H "Content-Type: application/json" \
-X GET "http://localhost:3000/api/v1/users?fields=id,first_name,last_name" // [!code focus]
{
"data": [
{
"id": 1,
"first_name": "Karl",
"last_name": "Popper"
},
{
"id": 2,
"first_name": "John",
"last_name": "Locke"
}
],
"pagination": {
"total": 2,
"lastPage": 1,
"perPage": 10,
"currentPage": 1,
"from": 0,
"to": 2
}
}
HTTP client can decide the sorting field and type. In the following example, records should be sorted by id
in descending order.
$ curl \
-H "Content-Type: application/json" \
-X GET "http://localhost:3000/api/v1/users?sort=-id" // [!code focus]
{
"data": [
{
"id": 2,
"email": "locke@axe-api.com",
"first_name": "John",
"last_name": "Locke",
"created_at": "2023-04-16T12:25:02.000Z",
"updated_at": "2023-04-16T12:25:02.000Z"
},
{
"id": 1,
"email": "karl@axe-api.com",
"first_name": "Karl",
"last_name": "Popper",
"created_at": "2023-04-16T11:31:44.000Z",
"updated_at": "2023-04-16T11:31:44.000Z"
}
],
"pagination": {
"total": 2,
"lastPage": 1,
"perPage": 10,
"currentPage": 1,
"from": 0,
"to": 2
}
}
Also, HTTP clients are able to filter data by sending a query;
$ curl \
-H "Content-Type: application/json" \
-X GET 'http://localhost:3000/api/v1/users?q=\{%22first_name%22:%22Karl%22\}' // [!code focus]
{
"data": [
{
"id": 1,
"email": "karl@axe-api.com",
"first_name": "Karl",
"last_name": "Popper",
"created_at": "2023-04-16T11:31:44.000Z",
"updated_at": "2023-04-16T11:31:44.000Z"
}
],
"pagination": {
"total": 1,
"lastPage": 1,
"perPage": 10,
"currentPage": 1,
"from": 0,
"to": 1
}
}
Next step
In this chapter, we tried to show what is Axe API and how it works in a simple example. You can be sure that there are more powerful features. This was just a demonstration.
In the next chapter, we are going to talk about Models and reveal their magical sides.