This guide is thinking to share my experience using Buffalo Framework for Golang and how to create API with JWT authentication in the simplest way.

Before starting

What will we use?

Buffalo with Pop like ORM

Golang

PostgresSQL & pgAdmin

JWT

I assume that …

You have installed Golang, Buffalo, and Postgres.

Let’s Go

Creating Buffalo API

To create our a new Buffalo API using next command:

1 $ buffalo new api_name --api

If you want to use another type of database, you can use this flag --db-type "mysql" or --db-type "sqlite3" by default buffalo use postgres. View more

Once buffalo finishes creating the project, it will show us the following messages:

1 2 3 INFO [ 2020-03-07T13:31:42-06:00 ] Congratulations! Your application, auth-api, has been successfully built! INFO [ 2020-03-07T13:31:42-06:00 ] You can find your new application at: /Users/saherla/Desktop/Little-Wire-Golang/auth_api INFO [ 2020-03-07T13:31:42-06:00 ] Please read the README.md file in your new application for next steps on running your application.

The name of my project is auth_api . Therefore we access the dir:

1 2 3 $ cd auth_api $ ls Dockerfile README.md actions config database.yml fixtures go.mod go.sum grifts inflections.json main.go models

You can use any text editor, in my case I’m going to use Visual Studio Code.

The most important dir’s for me are actions and models .

User Model

First, we generate our models using the buffalo commands:

The long command:

1 $ buffalo pop generate model user

The short command:

1 $ buffalo pop g m user

Note Check your list of plugins you must have installed pop.

1 2 3 4 5 6 $ buffalo plugins list | Bin | Command | Description | | ----------- | --------------------- | ---------------------------------------------- | | buffalo-pop | buffalo db | [ DEPRECATED ] please use ` buffalo pop ` instead. | | buffalo-pop | buffalo destroy model | Destroys model files. | | buffalo-pop | buffalo pop | A tasty treat for all your database needs |

When we create our models, buffalo automatically generates a migration dir, where our fizz files are stored to migrate our database to Postgres. One file is to upload our database and the other is to remove our database. View more

We will add new fields to our model. In this case, add username , email and password .

1 2 3 4 5 6 7 8 9 // User is used by pop to map your .model.Name.Proper.Pluralize.Underscore database table to your go code. type User struct { ID uuid . UUID `json:"id" db:"id"` CreatedAt time . Time `json:"created_at" db:"created_at"` UpdatedAt time . Time `json:"updated_at" db:"updated_at"` Username string `json:"username" db:"username"` Email string `json:"email" db:"email"` Password string `json:"password" db:"password"` }

In the same way, we add these fields to our fizz file. Keep in mind that it must be to the file _create_user.up.fizz .

1 2 3 4 5 6 7 create_table("users") { t.Column("id", "uuid", {primary: true}) t.Timestamps() t.Column("username", "string", {}) t.Column("email", "string", {}) t.Column("password", "string", {}) }

Validations User Model

Pop adds model-based validations, which is an excellent way to validate our API. View more

Validation Create

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 // ValidateCreate gets run every time you call "pop.ValidateAndCreate" method. // This method is not required and may be deleted. func ( u * User ) ValidateCreate ( tx * pop . Connection ) ( * validate . Errors , error ) { var err error return validate . Validate ( & validators . StringIsPresent { Field : u . Username , Name : "Username" }, & validators . EmailIsPresent { Field : u . Email , Name : "Email" , Message : "Incorrect Email Format" }, & validators . StringIsPresent { Field : u . Password , Name : "Password" }, & validators . StringLengthInRange { Field : u . Password , Name : "Password" , Min : 5 , Max : 50 , Message : "The password must be greater than 6 characters" }, // check to see if the email is already taken: & validators . FuncValidator { Field : u . Email , Name : "Email" , Message : "%s has already been registered!" , Fn : func () bool { var b bool q := tx . Where ( "email = ?" , u . Email ) if u . ID != uuid . Nil { q = q . Where ( "id != ?" , u . ID ) } b , err = q . Exists ( u ) if err != nil { return false } return ! b }, }, ), err }

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 // ValidateUpdate gets run every time you call "pop.ValidateAndUpdate" method. // This method is not required and may be deleted. func ( u * User ) ValidateUpdate ( tx * pop . Connection ) ( * validate . Errors , error ) { var err error return validate . Validate ( & validators . StringIsPresent { Field : u . Username , Name : "Username" }, & validators . EmailIsPresent { Field : u . Email , Name : "Email" , Message : "Incorrect Email Format" }, // check to see if the email is already taken: & validators . FuncValidator { Field : u . Email , Name : "Email" , Message : "%s has already been registered!" , Fn : func () bool { var b bool q := tx . Where ( "email = ?" , u . Email ) if u . ID != uuid . Nil { q = q . Where ( "id != ?" , u . ID ) } b , err = q . Exists ( u ) if err != nil { return false } return ! b }, }, ), err }

In this case, we will not be able to update the password in a future post we will perform an email verification.

Validations are quite important and interesting. You can see the validations available here.

Database Migration

You can configure your database in the database.yml file. I’ll leave the default setting.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 --- development : dialect : postgres database : auth_api_development user : postgres password : postgres host : 127.0.0.1 pool : 5 test : url : {{envOr "TEST_DATABASE_URL" "postgres://postgres: [email protected] :5432/auth_api_test?sslmode=disable" production : url : {{envOr "DATABASE_URL" "postgres://postgres: [email protected] :5432/auth_api_production?sslmode=disable"

First, we create the database using the buffalo commands.

1 2 3 4 5 6 7 8 9 $ buffalo db create -a v4.13.1 [ POP ] 2020/03/07 16:20:31 info - create auth_api_development ( postgres://postgres:[email protected]:5432/auth_api_development?sslmode = disable ) [ POP ] 2020/03/07 16:20:31 info - created database auth_api_development [ POP ] 2020/03/07 16:20:31 info - create auth_api_test ( postgres://postgres:[email protected]:5432/auth_api_test?sslmode = disable ) [ POP ] 2020/03/07 16:20:31 info - created database auth_api_test [ POP ] 2020/03/07 16:20:31 info - create auth_api_production ( postgres://postgres:[email protected]:5432/auth_api_production?sslmode = disable ) [ POP ] 2020/03/07 16:20:31 info - created database auth_api_production

Then we migrate our tables from the _create_user.up.fizz file

1 2 3 4 5 6 $ buffalo db migrate up v4.13.1 [ POP ] 2020/03/07 16:30:08 info - > create_users [ POP ] 2020/03/07 16:30:08 info - 0.1008 seconds [ POP ] 2020/03/07 16:30:08 warn - Migrator: unable to dump schema: exec: "pg_dump" : executable file not found in $PATH

In our pgAdmin, we can visualize the database created.

Callbacks

As the documentation says, “Pop provides a means to execute code before and after database operations.” View more

So we will create a callback to hash a user password.

1 2 3 4 5 6 7 8 9 // BeforeCreate callback to hash a user password. func ( u * User ) BeforeCreate ( tx * pop . Connection ) error { hash , err := bcrypt . GenerateFromPassword ([] byte ( u . Password ), bcrypt . DefaultCost ) if err != nil { return errors . WithStack ( err ) } u . Password = string ( hash ) return nil }

User Actions

Already created our models and database, we generate with the buffalo commands our actions.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 $ buffalo g action --help Generate new action ( s ) Usage: buffalo generate action [ name ] [ handler name... ] [ flags ] Aliases: action, a, actions Flags: -d, --dry-run dry run -h, --help help for action -m, --method string change the HTTP method for the generate action ( s ) ( default "GET" ) --skip-template skip generation of templates for action ( s ) -v, --verbose verbosely run the generator

If you want more information about the generation of actions. Here

Create Users

The long command:

1 $ buffalo generate actions users create -m "POST" --skip-template

The short command:

1 $ buffalo g a users create -m "POST" --skip-template

When we generate our actions automatically it generates the routing in the app.go file.

1 app . POST ( "/users/create" , UsersCreate )

I prefer that the methods (POST, GET, PATCH, PUT, DELETE…) define the action instead of indicating it on the route.

1 app . POST ( "/users" , UsersCreate )

In our users.go file we add the following buffalo.Handler necessary to insert our users into the database.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 // UsersCreate default implementation. func UsersCreate ( c buffalo . Context ) error { // User Model user := & models . User {} // Bind user to the json elements. if err := c . Bind ( user ); err != nil { return errors . WithStack ( err ) } // Get the DB connection from the context. tx , ok := c . Value ( "tx" ).( * pop . Connection ) if ! ok { return errors . WithStack ( errors . New ( "no transaction found" )) } // Validate and create the data. verrs , err := tx . ValidateAndCreate ( user ) if err != nil { return errors . WithStack ( err ) } // verrs.HasAny returns true/false depending on whether any errors // have been tracked. if verrs . HasAny () { c . Set ( "errors" , verrs ) return c . Error ( http . StatusConflict , errors . New ( verrs . Error ())) } return c . Render ( http . StatusCreated , r . Auto ( c , map [ string ] string { "message" : "User Created" })) }

We run the project to add a user.

1 $ buffalo dev

Note I will use curl to create the queries you can use any REST Client ( Insomnia Postman ). If you want to build curl commands you can use curlbuilder

Output

1 { "message" : "User Created" }

Success Perfect our API works correctly.

Read Users

The long command:

1 $ buffalo generate actions users read --skip-template

The short command:

1 $ buffalo g a users read --skip-template

In our app.go file, we change the path. As we did in the method of Create Users.

1 app . GET ( "/users" , UsersRead )

In users.go we add our buffalo.Handler necessary to read our users in the database.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 // UsersRead default implementation. func UsersRead ( c buffalo . Context ) error { users := & models . Users {} // Get the DB connection from the context. tx , ok := c . Value ( "tx" ).( * pop . Connection ) if ! ok { return errors . WithStack ( errors . New ( "no transaction found" )) } // Paginate results. Params "page" and "per_page" control pagination. // Default values are "page=1" and "per_page=20". // Add Order for date. q := tx . PaginateFromParams ( c . Params ()). Order ( "created_at asc" ) // Retrieve all Users from the DB. Select all except password. if err := q . Select ( "id" , "created_at" , "updated_at" , "username" , "email" , ). All ( users ); err != nil { return errors . WithStack ( err ) } // Add the paginator to the context so it can be used in the template. c . Set ( "pagination" , q . Paginator ) return c . Render ( http . StatusOK , r . Auto ( c , users )) }

We run the project and read the users.

1 $ buffalo dev

1 $ curl -XGET -H "Content-type: application/json" 'http://127.0.0.1:3000/users'

Output

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 [{ "id" : "377d57e8-8317-40e1-be5a-90c9cdd02e2a" , "created_at" : "2020-03-07T17:51:10.05641Z" , "updated_at" : "2020-03-07T17:51:10.056424Z" , "username" : "test1" , "email" : [email protected]" , "password" : "" }, { "id" : "c5fc20f9-a43a-4d14-b858-c9fad45847cb" , "created_at" : "2020-03-07T18:25:59.940466Z" , "updated_at" : "2020-03-07T18:25:59.940484Z" , "username" : "test2" , "email" : [email protected]" , "password" : "" }]

Read Users By ID

The long command:

1 $ buffalo generate actions users readByID --skip-template

The short command:

1 $ buffalo g a users readByID --skip-template

We change the path in our app.go file and add a parameter to extract user data depending on the ID.

1 app . GET ( "/users/{user_id}" , UsersReadByID )

In our users.go file we add our buffalo.Handler function necessary to read a user by ID

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 // UsersReadByID default implementation. func UsersReadByID ( c buffalo . Context ) error { user := & models . User {} // Get the DB connection from the context. tx , ok := c . Value ( "tx" ).( * pop . Connection ) if ! ok { return errors . WithStack ( errors . New ( "no transaction found" )) } // Retrieve a User from the DB. Select all except password. Using parameter "user_id". if err := tx . Select ( "id" , "created_at" , "updated_at" , "username" , "email" , ). Find ( user , c . Param ( "user_id" )); err != nil { return c . Error ( http . StatusNotFound , err ) } return c . Render ( http . StatusOK , r . Auto ( c , user )) }

We run the project and read the user by ID.

1 $ buffalo dev

We add the Hash ID in the path.

1 $ curl -XGET -H "Content-type: application/json" 'http://127.0.0.1:3000/users/377d57e8-8317-40e1-be5a-90c9cdd02e2a'

Output

1 2 3 4 5 6 7 8 { "id" : "377d57e8-8317-40e1-be5a-90c9cdd02e2a" , "created_at" : "2020-03-07T17:51:10.05641Z" , "updated_at" : "2020-03-07T17:51:10.056424Z" , "username" : "test1" , "email" : [email protected]" , "password" : "" }

The long command:

1 $ buffalo generate actions users update -m "PATCH" --skip-template

The short command:

1 $ buffalo g a users update -m "PATCH" --skip-template

Again, we change the path in our app.go file.

1 app . PATCH ( "/users/{user_id}" , UsersUpdate )

We add in users.go the code of our buffalo.Handler function to update users.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 // UsersUpdate default implementation. func UsersUpdate ( c buffalo . Context ) error { // Get the DB connection from the context. tx , ok := c . Value ( "tx" ).( * pop . Connection ) if ! ok { return errors . WithStack ( errors . New ( "no transaction found" )) } // Allocate an empty User user := & models . User {} if err := tx . Find ( user , c . Param ( "user_id" )); err != nil { return c . Error ( http . StatusNotFound , err ) } // Bind Framework to the html form elements if err := c . Bind ( user ); err != nil { return errors . WithStack ( err ) } // Validate the data and exclude colum password. verrs , err := tx . ValidateAndUpdate ( user , "password" ) if err != nil { return errors . WithStack ( err ) } // verrs.HasAny returns true/false depending on whether any errors // have been tracked. if verrs . HasAny () { c . Set ( "errors" , verrs ) return c . Error ( http . StatusConflict , errors . New ( verrs . Error ())) } return c . Render ( http . StatusCreated , r . Auto ( c , map [ string ] string { "message" : "User Updated" })) }

We test our API by updating a registered user with an existing ID in our database.

1 $ buffalo dev

1 $ curl -XPATCH -H "Content-type: application/json" -d '{"username": "test1Update"}' 'http://127.0.0.1:3000/users/377d57e8-8317-40e1-be5a-90c9cdd02e2a'

Output

1 { "message" : "User Updated" }

We check that our user has been updated.

1 $ curl -XGET -H "Content-type: application/json" 'http://127.0.0.1:3000/users/377d57e8-8317-40e1-be5a-90c9cdd02e2a'

1 2 3 4 5 6 7 8 { "id" : "377d57e8-8317-40e1-be5a-90c9cdd02e2a" , "created_at" : "2020-03-07T17:51:10.05641Z" , "updated_at" : "2020-03-08T02:29:12.155629Z" , "username" : "test1Update" , "email" : [email protected]" , "password" : "" }

Delete Users

The long command:

1 $ buffalo generate actions users delete -m "DELETE" --skip-template

The short command:

1 $ buffalo g a users delete -m "DELETE" --skip-template

We change the route to our last function in our app.go file.

1 app . DELETE ( "/users/{user_id}" , UsersDelete )

We add our buffalo.Handler function to delete a user in users.go .

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 // UsersDelete default implementation. func UsersDelete ( c buffalo . Context ) error { // Get the DB connection from the context tx , ok := c . Value ( "tx" ).( * pop . Connection ) if ! ok { return errors . WithStack ( errors . New ( "no transaction found" )) } // Allocate an empty User user := & models . User {} // To find the Widget the parameter widget_id is used. if err := tx . Find ( user , c . Param ( "user_id" )); err != nil { return c . Error ( 404 , err ) } if err := tx . Destroy ( user ); err != nil { return errors . WithStack ( err ) } return c . Render ( http . StatusCreated , r . Auto ( c , map [ string ] string { "message" : "User Deleted" })) }

Now we will try to delete a user.

1 $ buffalo dev

1 $ curl -XDELETE -H "Content-type: application/json" 'http://127.0.0.1:3000/users/377d57e8-8317-40e1-be5a-90c9cdd02e2a'

Output

1 { "message" : "User Deleted" }

We check if the user was deleted.

1 curl -XGET -H "Content-type: application/json" 'http://127.0.0.1:3000/users'

Output

1 2 3 4 5 6 7 8 [{ "id" : "c5fc20f9-a43a-4d14-b858-c9fad45847cb" , "created_at" : "2020-03-07T18:25:59.940466Z" , "updated_at" : "2020-03-07T18:25:59.940484Z" , "username" : "test2" , "email" : [email protected]" , "password" : "" }]

Perfect we realize that the user test1Update has been deleted.

Authentication

Once our CRUD is complete and working, we will need to protect our routes by having a login that returns a token to access the routes that we protect.

When we create our buffalo API add a .env configuration file, this file is very important because in it we will add our secret key to our token. We add a new variable called JWT_SECRET in the .env configuration file. You can add the value you want, in my case I put “Buffalo”.

1 2 3 4 5 6 7 8 9 10 11 # This .env file was generated by buffalo, add here the env variables you need # buffalo to load into the ENV on application startup so your application works correctly. # To add variables use KEY=VALUE format, you can later retrieve this in your application # by using os.Getenv("KEY"). # # Example: # DATABASE_PASSWORD=XXXXXXXXX # SESSION_SECRET=XXXXXXXXX # SMTP_SERVER=XXXXXXXXX JWT_SECRET="Buffalo"

Once added our secret key we generate a new action in my case I will call it auth.

1 $ buffalo g a auth login -m "POST" --skip-template

In the same way, as with user actions, it automatically creates our path in app.go and our new file auth.go where the logic for user authentication will go.

We change the route in app.go

1 app . POST ( "/users/auth" , AuthLogin )

Our function to authenticate the user in the auth.go file will have the following logic.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 // AuthLogin default implementation. func AuthLogin ( c buffalo . Context ) error { // User Model user := & models . User {} // Get the JWT Key Secret from .env file. secret := os . Getenv ( "JWT_SECRET" ) // Use Bind function to User model. if err := c . Bind ( user ); err != nil { return errors . WithStack ( err ) } // Save var of Request JSON Post. username := user . Username password := user . Password // We check if the username or password are not empty. if username == "" || password == "" { return c . Error ( http . StatusBadRequest , errors . New ( "Username and password cannot be empty" )) } // Get the DB connection from the context. tx , ok := c . Value ( "tx" ).( * pop . Connection ) if ! ok { return errors . WithStack ( errors . New ( "no transaction found" )) } // Find user with the username. q := tx . Select ( "id, username, password" ). Where ( "username= ?" , username ) err := q . First ( user ) if err != nil { if errors . Cause ( err ) == sql . ErrNoRows { // couldn't find an user with the supplied email. return c . Error ( http . StatusUnauthorized , errors . New ( "Invalid username or password" )) } return errors . WithStack ( err ) } // Get hashed password from db. PasswordHash := user . Password // Confirm that the given password matches the hashed password from the db err = bcrypt . CompareHashAndPassword ([] byte ( PasswordHash ), [] byte ( password )) if err != nil { return c . Error ( http . StatusUnauthorized , errors . New ( "Invalid username or password" )) } // Generate token with 2 hours expiration time. token := jwt . NewWithClaims ( jwt . SigningMethodHS256 , jwt . MapClaims { "id" : user . ID , "exp" : time . Now (). Add ( time . Hour * 2 ). Unix (), }) tokenString , err := token . SignedString ([] byte ( secret )) if err != nil { return errors . WithStack ( err ) } return c . Render ( http . StatusAccepted , r . Auto ( c , map [ string ] string { "token" : tokenString })) }

We test our login.

1 $ buffalo dev

1 $ curl -XPOST -H "Content-type: application/json" -d '{"username":"test2", "password":"test2"}' 'http://127.0.0.1:3000/users/auth'

Output

1 { "token" : "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1ODM3MjA2ODcsImlkIjoiYzVmYzIwZjktYTQzYS00ZDE0LWI4NTgtYzlmYWQ0NTg0N2NiIn0.SARm7AFhi8n3WXnMqo5YD0o_dJW_TNUPyyZO8hFWNG0" }

Perfect, once with our token, we need to protect our routes and thus be able to use that token, for this we will use a package called mw-tokenauth.

In our app.go file, we will add the following:

1 2 // Save AuthMiddleware function. AuthMiddleware := tokenauth . New ( tokenauth . Options {})

1 2 // Adding to my api the function. app . Use ( AuthMiddleware )

1 2 // Disable Auth Middleware in these fuctions app . Middleware . Skip ( AuthMiddleware , AuthLogin , UsersCreate )

So our App function should be seen as follows.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 func App () * buffalo . App { if app == nil { app = buffalo . New ( buffalo . Options { Env : ENV , SessionStore : sessions . Null {}, PreWares : [] buffalo . PreWare { cors . Default (). Handler , }, SessionName : "_auth_api_session" , }) // Automatically redirect to SSL app . Use ( forceSSL ()) // Log request parameters (filters apply). app . Use ( paramlogger . ParameterLogger ) // Set the request content type to JSON app . Use ( contenttype . Set ( "application/json" )) // Save AuthMiddleware function. AuthMiddleware := tokenauth . New ( tokenauth . Options {}) // Wraps each request in a transaction. // c.Value("tx").(*pop.Connection) // Remove to disable this. app . Use ( popmw . Transaction ( models . DB )) // Adding to my api the function. app . Use ( AuthMiddleware ) // Disable Auth Middleware in these fuctions app . Middleware . Skip ( AuthMiddleware , AuthLogin , UsersCreate ) app . GET ( "/" , HomeHandler ) app . POST ( "/users" , UsersCreate ) app . GET ( "/users" , UsersRead ) app . GET ( "/users/{user_id}" , UsersReadByID ) app . PATCH ( "/users/{user_id}" , UsersUpdate ) app . DELETE ( "/users/{user_id}" , UsersDelete ) app . POST ( "/users/auth" , AuthLogin ) } return app }

We tested our API.

1 $ buffalo dev

1 $ curl -XPOST -H "Content-type: application/json" -d '{"username":"test2", "password":"test2"}' 'http://127.0.0.1:3000/users/auth'

Output

1 { "token" : "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1ODM3MjIwNTcsImlkIjoiYzVmYzIwZjktYTQzYS00ZDE0LWI4NTgtYzlmYWQ0NTg0N2NiIn0.kSCFDKHpRlxqcBfoB8IK4UE335c2EyadSkev8whL9TE" }

If we want to read users we need to add our token to the headers as follows.

1 $ curl -XGET -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1ODM3MjIwNTcsImlkIjoiYzVmYzIwZjktYTQzYS00ZDE0LWI4NTgtYzlmYWQ0NTg0N2NiIn0.kSCFDKHpRlxqcBfoB8IK4UE335c2EyadSkev8whL9TE' -H "Content-type: application/json" 'http://127.0.0.1:3000/users'

As you can see the key of my header is Authorization and before placing the token I add Bearer, all this is found in the JWT documentation.

1 2 3 4 5 6 7 8 9 10 [ { "id" : "c5fc20f9-a43a-4d14-b858-c9fad45847cb" , "created_at" : "2020-03-07T18:25:59.940466Z" , "updated_at" : "2020-03-07T18:25:59.940484Z" , "username" : "test2" , "email" : [email protected]" , "password" : "" } ]

As a result I get registered users, in this case I only have one.

Now you have the task to test the other protected routes and add as many as you want.

You can see the project in my repository in Gitlab.