Introduction
An Application Programming Interface (API) is an intermediary software that allows your different applications to talk to each other. Located on your server, an API forwards your frontend clients' requests to a data provider like MySQL and responds in a standard JavaScript Object Notation (JSON) format. The frontend client can be a mobile app, desktop application, or web-based software.
An API automates your business workflows and makes your development cycle less costly as different applications can reuse your backend. On the marketing side, an API makes the personalization and adaptation of your software easier because third-party applications can consume your services by writing just a few lines of code.
In this tutorial, you'll create a modern API with Golang and MySQL 8 database servers. You'll use the API to expose products and categories resources from a sample store. Your final application will accept all Create, Read, Update, and Delete (CRUD) operations using different HTTP methods. While you can create the API with other server-side scripting languages, Golang is fast, easy to learn, scalable, and ships with comprehensive development tools, including a built-in web server.
Prerequisites
Before you proceed with this Golang API tutorial, you need:
1. Initialize a Database and a User Account for the API
In this sample application, you'll permanently store products and categories data on a MySQL database. Then, you'll write Golang scripts that will access the data using a dedicated MySQL user account. SSH to your server and execute the following steps to set up the database and user account.
Log in to your MySQL database server.
$ sudo mysql -u root -pEnter your
rootpassword for the database server when prompted and press Enter to proceed. Then, create a samplego_dbdatabase and ago_db_useraccount. ReplaceEXAMPLE_PASSWORDwith a strong value.mysql > CREATE DATABASE go_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE USER 'go_db_user'@'localhost' IDENTIFIED WITH mysql_native_password BY 'EXAMPLE_PASSWORD'; GRANT ALL PRIVILEGES ON go_db.* TO 'go_db_user'@'localhost'; FLUSH PRIVILEGES;Switch to the new
go_dbdatabase.mysql > USE go_db;Create the
productstable. This table stores the products' information including theproduct_id(primary key),product_name,category_id, andretail_price. These are just a few mandatory columns you'll need when developing a point of sale or e-commerce software. In a production environment, you may add other fields (For instance,cost_price,stock_level, and more) depending on the complexity of your application.mysql> CREATE TABLE products ( product_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, product_name VARCHAR(50), category_id BIGINT, retail_price DOUBLE ) ENGINE = InnoDB;Insert sample data into the
productstable.mysql> INSERT INTO products (product_name, category_id, retail_price) values ("WINTER JACKET", 1, "58.30"); INSERT INTO products (product_name, category_id, retail_price) values ("LEATHER BELT", 1, "14.95"); INSERT INTO products (product_name, category_id, retail_price) values ("COTTON VEST", 1, "2.95"); INSERT INTO products (product_name, category_id, retail_price) values ("WIRELESS MOUSE", 2, "19.45"); INSERT INTO products (product_name, category_id, retail_price) values ("FITNESS WATCH", 2, "49.60"); INSERT INTO products (product_name, category_id, retail_price) values ("DASHBOARD CLEANER", 3, "9.99"); INSERT INTO products (product_name, category_id, retail_price) values ("COMBINATION SPANNER", 3, "22.85"); INSERT INTO products (product_name, category_id, retail_price) values ("ENGINE DEGREASER", 3, "8.25");Confirm the records from the
productstable.mysql> SELECT product_id, product_name, category_id, retail_price FROM products;Output.
+------------+---------------------+-------------+--------------+ | product_id | product_name | category_id | retail_price | +------------+---------------------+-------------+--------------+ | 1 | WINTER JACKET | 1 | 58.3 | | 2 | LEATHER BELT | 1 | 14.95 | | 3 | COTTON VEST | 1 | 2.95 | | 4 | WIRELESS MOUSE | 2 | 19.45 | | 5 | FITNESS WATCH | 2 | 49.6 | | 6 | DASHBOARD CLEANER | 3 | 9.99 | | 7 | COMBINATION SPANNER | 3 | 22.85 | | 8 | ENGINE DEGREASER | 3 | 8.25 | +------------+---------------------+-------------+--------------+ 8 rows in set (0.01 sec)Next, create the
categoriestable. This table categorizes your inventory to help you navigate through your collection.mysql> CREATE TABLE categories ( category_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, category_name VARCHAR(50), description VARCHAR(50) ) ENGINE = InnoDB;Enter sample data into the
categoriestable.mysql> INSERT INTO categories (category_name, description) values ("APPAREL", "Stores different clothing"); INSERT INTO categories (category_name, description) values ("ELECTRONICS", "Stores different electronics"); INSERT INTO categories (category_name, description) values ("CAR ACCESSORIES", "Stores car DIY items");Query the
categoriestable to confirm the records.mysql> SELECT category_id, category_name, description FROM categories;Output.
+-------------+-----------------+------------------------------+ | category_id | category_name | description | +-------------+-----------------+------------------------------+ | 1 | APPAREL | Stores different clothing | | 2 | ELECTRONICS | Stores different electronics | | 3 | CAR ACCESSORIES | Stores car DIY items | +-------------+-----------------+------------------------------+ 3 rows in set (0.00 sec)To authenticate users, you'll need a table to store the usernames and passwords, create the
system_userstable.mysql > CREATE TABLE system_users ( user_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50), password VARCHAR(255) ) ENGINE = InnoDB;Insert a sample
john_doeuser account into thesystem_userstable. The password of the user isEXAMPLE_PASSWORDalready hashed with thebcryptalgorithm. You can use online tools like Bcrypt-Generator.com to hash any password that you want to use to test this guide. In a production environment, you may create a registration script to capture the usernames and password hashes and post them into thesystem_userstable.For now, execute the following statement to create a user account manually.
mysql> INSERT INTO system_users (username, password) VALUES ('john_doe', '$2a$12$JOe5OFLD9dFkI.KJ1k9TP.ixWX/YtYArB/Yv.A8XSeIcCBkIlPvoi');Log out from the MySQL database server.
mysql> EXIT;
2. Create a Project Directory for the API
You'll store your API development files in a separate directory. This will make troubleshooting and debugging your application easier in case you encounter any errors.
Create a
projectdirectory.$ mkdir projectNavigate to the new
projectdirectory.$ cd projectYou'll now add new source code files to the new directory.
3. Create the Golang API Scripts
In this sample API, you'll separate the actions/methods of your application using different functions grouped in separate files.
Create the main.go Script
The first file you need to create is Golang's entry point. This file holds the main function that will be executed when your application runs.
Create the
main.gofile usingnano.$ nano main.goEnter the following information into the file.
package main import ( "net/http" "encoding/json" "fmt" ) func main() { http.HandleFunc("/api/v1/", requestHandler) http.ListenAndServe(":8081", nil) } func requestHandler(w http.ResponseWriter, req *http.Request) { w.Header().Set("Content-Type", "application/json") var reqError error responseBody := map[string]interface{}{} requestData := map[string]interface{}{} request, reqError := getRequest(req) if reqError == nil { authenticated, authErr := authenticateUser(req) if authErr != nil || authenticated == false { reqError = &requestError{ statusCode: 401, message: "Authentication failed.", errorCode: "401", resource : "", } } else { switch request.resource { case "products": requestData, reqError = newProducts(request) case "categories": requestData, reqError = newCategories(request) default: reqError = &requestError{ statusCode: 404, message: "Resource not found.", errorCode: "404", resource : "", } } } } if reqError != nil { if requestError, ok := reqError.(*requestError); ok { w.WriteHeader(requestError.statusCode) err := map[string]interface{}{ "status_code": requestError.statusCode, "message": requestError.message, "error_code": requestError.errorCode, "resource": requestError.resource, } responseBody["error"] = err } } else { w.WriteHeader(request.statusCode) responseBody = requestData } enc := json.NewEncoder(w) enc.SetIndent("", " ") if err := enc.Encode(responseBody); err != nil { fmt.Println(err.Error()) } }Save and close the file.
In the above
main.gofile, the first statement,package main, initializes your package name. Then, you've created a main functionfunc main() {...}that routes requests to therequestHandler(...){...}function.Then, you're importing different packages that you'll use in this file, including the
net/http,encoding/json, andfmt.You're using the
*http.Requestvariable to capture users' requests to your web application and then, you're writing a response back using the HTTP response writerhttp.ResponseWriter.The line
request, reqError := getRequest(req)routes the HTTP request to arequest.gofile, which structures the request with more custom information that your API understands.Then, after you've retrieved the type of request, you're checking if the user is authenticated into the system by sending the HTTP request to an
authentication.gofile, which has anauthenticateUserfunction. This function compares the user-suppliedusernameandpasswordwith the values stored in the database to see if there is a matching user account.After you establish the user is authenticated, you're using the
switch request.resource {...}to establish the resource that the user is requesting. For this guide, you'll create only two resources. That is theproductsand thecategoriesresources. In a real-life application, you can have tens or even hundreds of API endpoints.Finally, you're encoding the response to a JSON format using the
json.NewEncoder(w)library.
Create a request.go Script
In your main.go file, you've written the line request, reqError := getRequest(req), this line talks to the request.go file.
Create the
request.gofile usingnano.$ nano request.goThen, paste the following information into the file.
package main import ( "net/http" "encoding/json" "strings" "strconv" "fmt" ) type request struct { method string resource string resourceId interface{} params map[string]interface{} statusCode int page int perPage int fields string sort string } func getRequest(req *http.Request) (request, error) { var newRequest request urlParts := strings.Split(req.URL.Path, "/") params := map[string]interface{}{} if req.Method == "GET" { for k, v := range req.URL.Query() { params[k] = v[0] } } else if req.Method == "POST" || req.Method == "PUT" { err := json.NewDecoder(req.Body).Decode(¶ms) if err != nil { return newRequest, &requestError{ statusCode: 400, message: err.Error(), errorCode: "400", resource : "request/getRequest", } } } else if req.Method == "DELETE" { //When using the DELETE HTTP, there is no request body } else { return newRequest, &requestError{ statusCode: 405, message: "Method not supported.", errorCode: "405", resource : "", } } currentPage := 1 page, err := strconv.Atoi(fmt.Sprint(params["page"])) if err == nil { if page >= 1 { currentPage = page } } pageSize := -1 perPage, err := strconv.Atoi(fmt.Sprint(params["per_page"])) if err == nil { if perPage >= 1 { pageSize = perPage } } fields := "" sort := "" if params["fields"] != nil { fields = params["fields"].(string) } if params["sort"] != nil { sort = params["sort"].(string) } if len(urlParts) >= 5 { newRequest.resourceId = urlParts[4] } newRequest.method = req.Method newRequest.params = params newRequest.page = currentPage newRequest.perPage = pageSize newRequest.fields = fields newRequest.sort = sort newRequest.resource = urlParts[3] if req.Method == "POST" { newRequest.statusCode = 201 } else { newRequest.statusCode = 200 } return newRequest, nil }Save and close the file.
At the beginning of the
request.gofile, you're still definingpackage mainand importing all the necessary libraries. Then, in this file, you're using the statementurlParts := strings.Split(req.URL.Path, "/")to extract the requestedresourceas well as theresourceId. For instance, if a user requests the resourcehttp://host_name/api/v1/products/3, you're only interested in retrieving the resource(products) and the resourceId(3).Then, in this function, you're storing any
GET,POST, andPUTparameters to the mapparamsthat you've defined using the statementparams := map[string]interface{}{}. You're not expecting any parameters for the HTTPDELETEmethod.Towards the end of the file, you're defining a HTTP status code that your API users will receive when they get a response. For any
POSTrequest, your API will answer with status code201meaning a new resource has been created or status code200forGET,DELETEandPUToperations. Then, you're returning a new request with additional information about the request including the HTTPmethod,params,page,perPage, requestedfields,sortparameters,resource, and theresourceId.
Create a requesterror.go File
When designing an API, the errors you want to return to your clients should be descriptive enough and carry additional HTTP error status codes. In this guide, you'll create a separate file to handle this functionality.
Use Nano to create a
requesterror.gofile.$ nano requesterror.goThen, enter the information into the file.
package main import ( "fmt" ) type requestError struct { statusCode int message string errorCode string resource string } func (e *requestError) Error() string { return fmt.Sprintf("statusCode %d: message %v: errorCode %v", e.statusCode , e.message, e.errorCode) }Save and close the file
In the above file, you're creating a custom
requestErrorstruct to extend errors into a more meaningful format. When returning the errors to the client, you'll include astatusCodeof type integer corresponding to the actual HTTP status code. For instance,400(bad request),500(internal server error), or404(resource not found), and more.Then, you include a human-readable
messageand a customerrorCode. For this guide, you can repeat the HTTPstatusCodeas theerrorCodebut in more advanced software, you might come up with your personalerrorCodes. Then, finally, include the actualresourcethat triggered the error to make troubleshooting easier.
Create a pagination.go Script
When displaying data in your API, you must develop a paging algorithm to page data into manageable chunks to save bandwidth and avoid overloading the database server.
Luckily, in MySQL, you can use the LIMIT clause to achieve this functionality. To keep things simple, you'll create a separate script to handle pagination.
Use
nanoto create apagination.gofile.$ nano pagination.goThen, enter the following information below into the file.
package main import ( "strconv" ) func getLimitString (page int, perPage int) (string) { limit := " " if perPage != -1 { limit = " limit " + strconv.Itoa((page - 1) * perPage) + ", " + strconv.Itoa(perPage) } return limit } func getMeta(dg dbGateway, queryString string, queryParams []interface{}) (map[string]int, error) { db, err := openDb() if err != nil { return nil, err } stmt, err := db.Prepare("select count(*) as totalRecords from (" + queryString + ") tmp") if err != nil { return nil, err } defer stmt.Close() totalRecords := 0 err = stmt.QueryRow(queryParams...).Scan(&totalRecords) if err != nil { return nil, err } totalPages := 0 if dg.perPage != -1 { totalPages = totalRecords/dg.perPage } else { totalPages = 1 } if totalRecords % dg.perPage > 0 { totalPages++ } meta := map[string]int { "page": dg.page, "per_page": dg.perPage, "count": totalRecords, "total_pages": totalPages, } if err != nil { return nil, err } return meta, nil }Save and close the file
The above file has two functions. You're using the
getLimitStringfunction to examine thepageandperPagevariables to craft anoffsetandlimitclause using the formulalimit " + strconv.Itoa((page - 1) * perPage) + ", " + strconv.Itoa(perPage). For instance, if the user requestspage1 and wants to retrieve 25 recordsperPage, thelimitclause will be as follows.limitClause = limit " + strconv.Itoa((page - 1) * perPage) + ", " + strconv.Itoa(perPage) limitClause = limit " + strconv.Itoa((1 - 1) * 25) + ", " + strconv.Itoa(25) limitClause = limit " + 0 * 25 + ", " + 25 limitClause = limit " + 0 + ", " + 25 limitClause = limit " + 0 + ", " + 25 limitClause = limit 0 , 25If the user wants page 2, your
limitClausewill be.limitClause = limit " + strconv.Itoa((page - 1) * perPage) + ", " + strconv.Itoa(perPage) limitClause = limit " + strconv.Itoa((2 - 1) * 25) + ", " + strconv.Itoa(25) limitClause = limit " + 1 * 25 + ", " + 25 limitClause = limit " + 25 + ", " + 25 limitClause = limit " + 25 + ", " + 25 limitClause = limit 25 , 25Then, you have the
getMetafunction which takes aqueryStringand uses the MySQL aggregateCOUNT(*)function to determine thetotalRecordsandtotalpagesusing the following formula. If the second expression below returns a fraction, you must add 1 page to the result because you can not have a page with fractions - the value of thetotalPagesmust be an integer.totalPages = totalRecords/dg.perPage ... if totalRecords % dg.perPage > 0 { totalPages++ } ...
Create a sorting.go Script
When clients request data from your API, they should sort the data in ascending or descending order with the different fields depending on their use case. To achieve this functionality, you'll create a sorting algorithm that takes the user-supplied sort fields and turns them into a statement that your MySQL database can understand.
Create the
sorting.gofile.$ nano sorting.goThen, paste the following information into the file.
package main import ( "strings" ) func getSortFields(sortableFields string, sortParams string) (string, error) { sortableFieldsSlice := strings.Split(sortableFields, ",") sortParamsSlice := strings.Split(sortParams, ",") i := 0 sort := "" for _, field := range sortParamsSlice { for _, allowList := range sortableFieldsSlice { if strings.TrimSpace(strings.TrimPrefix(field, "-")) == strings.TrimSpace(allowList) { if strings.HasPrefix(field, "-") == true { sort = sort + strings.TrimPrefix(field, "-") + " desc" + "," } else { sort = sort + field + " asc" + "," } } } i++ } sort = strings.TrimSuffix(sort, ",") return sort, nil }Save and close the file.
The
getSortFieldsfunction above accepts two variables, an allowlist of thesortableFieldsthat you want to allow in your API, and a comma-separated string retrieved from thesortURL parameter.You're looping through the user-supplied values to clean the sort parameters. In case a field is not in the allowlist, just drop and ignore it. Then, because you don't want users to include the
ascordescpart when defining their custom sort fields, you'll treat any field prefixed with a minus sign-to meandescendingorder.For instance, if a user enters the URL
http://example.com/api/v1/products?sort=-product_name,retail_price, your script crafts a sort string similar toproduct_name desc, retail_price asc.
Create a customfields.go Script
Different clients will consume your API. Some of them, like mobile apps, have resource constraints. Therefore, you should allow API consumers to define the fields that they want to be returned from a resource. For instance, in your products resource, your API users can retrieve only the product_name fields by requesting the URL http://example.com/api/v1/products?fields=product_name.
To achieve this functionality, create the
customfields.gofile.$ nano customfields.goThen, paste the following information into the file.
package main import ( "strings" "errors" ) func cleanFields(defaultFields string, urlFields string) (string, error) { fields := "" if urlFields == "" { fields = defaultFields } else { urlFieldsSlice := strings.Split(urlFields, ",") defaultFieldsSlice := strings.Split(defaultFields , ",") for _, x := range urlFieldsSlice { for _, y := range defaultFieldsSlice { if strings.TrimSpace(x) == strings.TrimSpace(y) { fields = fields + x + "," } } } fields = strings.TrimSuffix(fields, ",") } if fields == "" { err := errors.New("Invalid fields.") return "", err } else { return fields, nil } }Save and close the file
You're using the
cleanFields()function above to accept thedefaultFieldsthat are returned in case the API consumer doesn't define any custom fields and theurlFieldsretrieved from thefieldsURL parameter.Then, you're looping through the user-supplied fields and dropping any non-allowed values, and you're returning the clean fields to be used in a MySQL
SELECTstatement.
Create a dbgateway.go Script
Your API will connect to the MySQL database that you defined at the beginning of the guide. Because you might have several files connecting to the database, it is conventional to create a single gateway file for connecting, querying, and executing SQL statements.
Create
dbgateway.gofile.$ nano dbgateway.goThen, paste the information below into the file.
package main import ( _"github.com/go-sql-driver/mysql" "database/sql" ) type dbGateway struct { page int perPage int sort string resource string } func openDb() (*sql.DB, error) { dbServer := "127.0.0.1" dbPort := "3306" dbUser := "go_db_user" dbPassword := "EXAMPLE_PASSWORD" dbName := "go_db" conString := dbUser + ":" + dbPassword + "@tcp(" + dbServer + ":" + dbPort + ")/" + dbName db, err := sql.Open("mysql", conString) if err != nil { return nil, err } pingErr := db.Ping() if pingErr != nil { return nil, &requestError{ statusCode: 500, message: "Error opening database.", errorCode: "500", resource : "dbgateway/openDb", } } return db, nil } func dbExecute(dg dbGateway, queryString string, paramValues []interface{}) (map[string]interface{}, error) { db, err := openDb() if err != nil { return nil, err } stmt, err := db.Prepare(queryString) if err != nil { return nil, &requestError{ statusCode: 500, message: "Error preparing execute query. " + err.Error(), errorCode: "500", resource : dg.resource, } } defer stmt.Close() result, err := stmt.Exec(paramValues...) if err != nil { return nil, &requestError{ statusCode: 500, message: "Error executing statement", errorCode: "500", resource : dg.resource, } } defer stmt.Close() response := map[string]interface{}{} LastInsertId, err := result.LastInsertId() if err != nil { return nil, &requestError{ statusCode: 500, message: "Error retrieving last insert id.", errorCode: "500", resource : dg.resource, } } else { response["LastInsertId"] = LastInsertId return response, nil } } func dbQuery(dg dbGateway, queryString string, paramValues []interface{}, resourceId interface{}) (map[string]interface{}, error) { db, err := openDb() if err != nil { return nil, err } limit := getLimitString(dg.page, dg.perPage) sort := " order by " + dg.sort stmt, err := db.Prepare(queryString + sort + limit) if err != nil { return nil, &requestError{ statusCode: 500, message: "Error preparing execute query. " + err.Error(), errorCode: "500", resource : dg.resource, } } defer stmt.Close() rows, err := stmt.Query(paramValues...) if err != nil { return nil, &requestError{ statusCode: 500, message: "Error retrieving rows.", errorCode: "500", resource : dg.resource, } } defer rows.Close() columns, err := rows.Columns() if err != nil { return nil, &requestError{ statusCode: 500, message: "Error retrieving columns", errorCode: "500", resource : dg.resource, } } data := []map[string]interface{}{} count := len(columns) values := make([]interface{}, count) scanArgs := make([]interface{}, count) for i := range values { scanArgs[i] = &values[i] } for rows.Next() { err := rows.Scan(scanArgs...) if err != nil { return nil, &requestError{ statusCode: 500, message: "Error scanning rows", errorCode: "500", resource : dg.resource, } } tbRecord := map[string]interface{}{} for i, col := range columns { v := values[i] b, ok := v.([]byte) if (ok) { tbRecord[col] = string(b) } else { tbRecord[col] = v } } data = append(data, tbRecord) } response := map[string]interface{}{} if resourceId == nil { meta, err := getMeta(dg, queryString, paramValues) if err != nil { return nil, err } response["data"] = data response["meta"] = meta } else { if len(data) < 1 { return nil, &requestError{ statusCode: 404, message: "Record not found", errorCode: "404", resource : dg.resource, } } response["data"] = data[0] } return response, nil }Save and close the file
In the above file, you have defined a
dbGatewaystruct which allows your calling files to pass thepage,perPage,sort, andresourcevalues to thedbQueryanddbExecutefunctions because you need these values to fulfill some business logic. Then, you're using theopenDb()function to open your database using the user account you created earlier.The
dbQueryfunction executes parameterizedSELECTqueries and thedbExecutefunction handles parameterizedINSERT,UPDATE, andDELETEstatements. In thedbQueryfunction, you're using the functions in thepagination.goscript to get thelimitclause and themetainformation. Finally, you're returning a map of type...[string]interface{}...to the calling script.
Create an authentication.go Script
To authenticate users into your API, you'll check their usernames and passwords and compare them against the database values. You'll do this in the main.go file which calls a function in an authentication.go file.
Create the
authentication.gofile$ nano authentication.goThen, paste the below information into the file.
package main import ( "net/http" "golang.org/x/crypto/bcrypt" ) func authenticateUser(req *http.Request)(bool, error) { reqUsername, reqPassword, ok := req.BasicAuth() if ok { db, err := openDb() if err != nil { return false, err } queryString := "select password from system_users where username = ?" stmt, err := db.Prepare(queryString) if err != nil { return false, err } defer stmt.Close() storedPassword := "" err = stmt.QueryRow(reqUsername).Scan(&storedPassword) if err != nil { return false, err } if err := bcrypt.CompareHashAndPassword([]byte(storedPassword), []byte(reqPassword)); err != nil { return false, err } else { return true, err } } else { return false, nil } }Save and close the file.
The
authenticateUserfunction retrieves the user-suppliedusernameandpassword, retrieves the appropriate record from thesystem_userstable, and uses thebcrypt.CompareHashAndPassword(....)function to check if the password and hash match. This function returns a booleantruevalue if the user is authenticated orfalseif the user credentials do not exist in the database.
4. Create the Resource Files
After you've all the supportive scripts for your API, you can now create the actual resource files that you'll serve.
Create a products.go Resource File
This resource file will allow API consumers to create, update, delete, and retrieve items from the products table.
Create a
products.gofile.$ nano products.goPaste the information below into the file.
package main import ( "strconv" "fmt" ) func newProducts(r request)(map[string]interface{}, error) { action := r.method var err error data := map[string]interface{}{} resourceId, _ := strconv.Atoi(fmt.Sprint(r.resourceId)) switch action { case "POST": data, err = createProduct(r.params) case "PUT": data, err = updateProduct(resourceId, r.params) case "DELETE": data, err = deleteProduct(resourceId) case "GET": data, err = getProducts(r) } if err != nil { return nil, err } else { return data, nil } } func validateProductData(params map[string]interface{}) string { validationError := "" if val, ok := params["product_name"]; ok { if val.(string) == "" { validationError = validationError + `Invalid product_name` } } else { validationError = validationError + "\n" + `Field product_name is required.` } if val, ok := params["retail_price"]; ok { retailPrice, err := strconv.ParseFloat(fmt.Sprint(val), 64) if err != nil || retailPrice <= 0 { validationError = validationError + "\n" + `Invalid retail_price` } } else { validationError = validationError + "\n" + `Field retail_price is required.` } if val, ok := params["category_id"]; ok { categoryId, err := strconv.Atoi(fmt.Sprint(val)) if err != nil || categoryId <= 0 { validationError = validationError + "\n" + `Invalid category_id` } } else { validationError = validationError + "\n" + `Field category_id is required.` } return validationError } func createProduct(params map[string]interface{}) (map[string]interface{}, error) { validationError := validateProductData(params) if validationError != "" { return nil, &requestError{ statusCode: 400, message: validationError, errorCode: "400", resource : "products", } } var dg dbGateway dg.resource = "products" queryString := "insert into products(product_name, category_id, retail_price) values (?,?,?)" paramValues := []interface{}{ params["product_name"], params["retail_price"], params["category_id"], } dbExecuteResponse, err:= dbExecute(dg, queryString, paramValues) if err != nil { return nil, err } result := map[string]interface{}{} response := map[string]interface{}{ "product_id": dbExecuteResponse["LastInsertId"], "product_name": params["product_name"], "category_id": params["category_id"], "retail_price": params["retail_price"], } result["data"] = response return result, nil } func updateProduct(resourceId interface{}, params map[string]interface{}) (map[string]interface{}, error) { validationError := validateProductData(params) if validationError != "" { return nil, &requestError{ statusCode: 400, message: validationError, errorCode: "400", resource : "products", } } var dg dbGateway dg.resource = "products" queryString := "update products set product_name = ?, category_id = ? ,retail_price = ? where product_id = ?" paramValues := []interface{}{ params["product_name"], params["category_id"], params["retail_price"], resourceId, } _, err:= dbExecute(dg, queryString, paramValues) if err != nil { return nil, err } result := map[string]interface{}{} response := map[string]interface{}{ "product_id" : resourceId, "product_name": params["product_name"], "category_id": params["category_id"], "retail_price": params["retail_price"], } result["data"] = response return result, nil } func deleteProduct(resourceId interface{}) (map[string]interface{}, error) { var dg dbGateway dg.resource = "products" queryString := "delete from products where product_id = ? limit 1" paramValues := []interface{}{ resourceId, } _, err:= dbExecute(dg, queryString, paramValues) if err != nil { return nil, err } result := map[string]interface{}{} result["data"] = "Success" return result, nil } func getProducts(r request)(map[string]interface{}, error) { var dg dbGateway dg.page = r.page dg.perPage = r.perPage dg.resource = "products" defaultFields := "product_id, product_name, category_id, retail_price" var fields string var err error if r.fields != "" { fields, err = cleanFields(defaultFields, r.fields) } else { fields = defaultFields } if err != nil { return nil, err } defaultSortFields := "product_id asc, product_name asc, category_id asc" sortableFields := "product_id, product_name, category_id" sortFields := "" if r.sort != "" { sortFields, err = getSortFields(sortableFields, r.sort) if err != nil { return nil, err } } else { sortFields = defaultSortFields } dg.sort = sortFields queryString := "" paramValues := []interface{}{} if r.resourceId != nil { queryString = `select ` + fields + ` from products` + ` where product_id = ?` paramValues = append(paramValues, r.resourceId) } else { filter := "" if r.params["search"] != nil { filter = "and product_name like ?" paramValues = append(paramValues, r.params["search"].(string) + "%") } queryString = `select ` + fields + ` from products` + ` where products.product_id > 0 ` + filter } data, err := dbQuery(dg, queryString, paramValues, r.resourceId) if err != nil { return nil, err } return data, nil }Save and close the file.
In the above file, you've defined the entry function
newProductswhich will be called after themain.gofunction receives a request matching theproductsresource.Then, you're using the Golang switch statement to route the request to the appropriate function depending on the request method as follows.
POST: createProduct function PUT: updateProduct function DELETE: deleteProduct function GET: getProducts functionThe
validateProductDatafunction validates data when executing aPOSTor aPUToperation.
Create a categories.go Resource File
The next resource that you're going to create is the categories file. This resource will return the different categories in your sample application. You can also use it to create, edit, and update records in the categories table.
Create the categories.go file.
$ nano categories.goThen, paste the information below into the file.
package main import ( "strconv" "fmt" ) func newCategories(r request)(map[string]interface{}, error) { action := r.method var err error data := map[string]interface{}{} resourceId, _ := strconv.Atoi(fmt.Sprint(r.resourceId)) switch action { case "POST": data, err = createCategory(r.params) case "PUT": data, err = updateCategory(resourceId, r.params) case "DELETE": data, err = deleteCategory(resourceId) case "GET": data, err = getCategories(r) } if err != nil { return nil, err } else { return data, nil } } func validateCategoryData(params map[string]interface{}) string { validationError := "" if val, ok := params["category_name"]; ok { if val.(string) == "" { validationError = validationError + `Invalid category_name` } } else { validationError = validationError + "\n" + `Field category_name is required.` } if val, ok := params["description"]; ok { if val.(string) == "" { validationError = validationError + `Invalid description` } } else { validationError = validationError + "\n" + `Field description is required.` } return validationError } func createCategory(params map[string]interface{}) (map[string]interface{}, error) { validationError := validateCategoryData(params) if validationError != "" { return nil, &requestError{ statusCode: 400, message: validationError, errorCode: "400", resource : "categories", } } var dg dbGateway dg.resource = "categories" queryString := "insert into categories(category_name, description) values (?,?)" paramValues := []interface{}{ params["category_name"], params["description"], } dbExecuteResponse, err:= dbExecute(dg, queryString, paramValues) if err != nil { return nil, err } result := map[string]interface{}{} response := map[string]interface{}{ "category_id": dbExecuteResponse["LastInsertId"], "category_name": params["category_name"], "description": params["description"], } result["data"] = response return result, nil } func updateCategory(resourceId interface{}, params map[string]interface{}) (map[string]interface{}, error) { validationError := validateCategoryData(params) if validationError != "" { return nil, &requestError{ statusCode: 400, message: validationError, errorCode: "400", resource : "categories", } } var dg dbGateway dg.resource = "categories" queryString := "update categories set category_name = ?, description = ? where category_id = ?" paramValues := []interface{}{ params["category_name"], params["description"], resourceId, } _, err:= dbExecute(dg, queryString, paramValues) if err != nil { return nil, err } result := map[string]interface{}{} response := map[string]interface{}{ "category_id" : resourceId, "category_name": params["category_name"], "description": params["description"] , } result["data"] = response return result, nil } func deleteCategory(resourceId interface{}) (map[string]interface{}, error) { var dg dbGateway dg.resource = "categories" queryString := "delete from categories where category_id = ? limit 1" paramValues := []interface{}{ resourceId, } _, err:= dbExecute(dg, queryString, paramValues) if err != nil { return nil, err } result := map[string]interface{}{} result["data"] = "Success" return result, nil } func getCategories(r request)(map[string]interface{}, error) { var dg dbGateway dg.resource = "categories" dg.page = r.page dg.perPage = r.perPage defaultFields := "category_id, category_name, description" var fields string var err error if r.fields != "" { fields, err = cleanFields(defaultFields, r.fields) } else { fields = defaultFields } if err != nil { return nil, err } defaultSortFields := "category_id asc, category_name asc" sortableFields := "category_id, category_name" sortFields := "" if r.sort != "" { sortFields, err = getSortFields(sortableFields, r.sort) if err != nil { return nil, err } } else { sortFields = defaultSortFields } dg.sort = sortFields queryString := "" paramValues := []interface{}{} if r.resourceId != nil { queryString = "select " + fields + " from categories where category_id = ?" paramValues = append(paramValues, r.resourceId) } else { filter := "" if r.params["search"] != nil { filter = "and category_name like ?" paramValues = append(paramValues, r.params["search"].(string) + "%") } queryString = "select " + fields + " from categories where category_id > 0 " + filter } data, err := dbQuery(dg, queryString, paramValues, r.resourceId) if err != nil { return nil, err } return data, nil }Save and close the file.
Like in the
products.goresource file, thenewCategoriesfunction in the abovecategories.gofile routes the different CRUD operations to the appropriate functions to create, update, delete, and retrieve categories.
5. Test the Golang API
After you've finished editing all the API source files, the next step is testing your application's different functions.
Run the program by executing the following function. Ensure you're still in the
projectdirectory.$ go get github.com/go-sql-driver/mysql $ go get golang.org/x/crypto/bcrypt $ go run ./The last command above has a blocking function that allows your API to listen on port
8081.Open another terminal window and use
curlto retrieve items from theproductsresource. The -i option allows you to retrieve the headers to make sure your API is returning the correct HTTP status codes.$ curl -i -u john_doe:EXAMPLE_PASSWORD -X GET "localhost:8081/api/v1/products"Output.
{ "data": [ { "category_id": 1, "product_id": 1, "product_name": "WINTER JACKET", "retail_price": 58.3 }, { "category_id": 1, "product_id": 2, "product_name": "LEATHER BELT", "retail_price": 14.95 }, { "category_id": 1, "product_id": 3, "product_name": "COTTON VEST", "retail_price": 2.95 }, { "category_id": 2, "product_id": 4, "product_name": "WIRELESS MOUSE", "retail_price": 19.45 }, { "category_id": 2, "product_id": 5, "product_name": "FITNESS WATCH", "retail_price": 49.6 }, { "category_id": 3, "product_id": 6, "product_name": "DASHBOARD CLEANER", "retail_price": 9.99 }, { "category_id": 3, "product_id": 7, "product_name": "COMBINATION SPANNER", "retail_price": 22.85 }, { "category_id": 3, "product_id": 8, "product_name": "ENGINE DEGREASER", "retail_price": 8.25 } ], "meta": { "count": 8, "page": 1, "per_page": -1, "total_pages": 1 } }Attempt to retrieve the first product with a
product_idof 1.$ curl -i -u john_doe:EXAMPLE_PASSWORD -X GET "localhost:8081/api/v1/products/1"Output.
HTTP/1.1 200 OK Content-Type: application/json Date: Mon, 01 Nov 2021 10:55:23 GMT Content-Length: 125 { "data": { "category_id": 1, "product_id": 1, "product_name": "WINTER JACKET", "retail_price": 58.3 } }Experiment with custom fields. Retrieve only the
product_idandproduct_namefields.$ curl -i -u john_doe:EXAMPLE_PASSWORD -X GET "localhost:8081/api/v1/products/1?fields=product_id,product_name"Output.
HTTP/1.1 200 OK Content-Type: application/json Date: Mon, 01 Nov 2021 10:56:29 GMT Content-Length: 77 { "data": { "product_id": 1, "product_name": "WINTER JACKET" } }Experiment with pages. Retrieve page 1 from the
productsresource and specify a page size of 3 records.$ curl -i -u john_doe:EXAMPLE_PASSWORD -X GET "localhost:8081/api/v1/products?page=1&per_page=3"Output
HTTP/1.1 200 OK Content-Type: application/json Date: Mon, 01 Nov 2021 10:59:03 GMT Content-Length: 483 { "data": [ { "category_id": 1, "product_id": 1, "product_name": "WINTER JACKET", "retail_price": 58.3 }, { "category_id": 1, "product_id": 2, "product_name": "LEATHER BELT", "retail_price": 14.95 }, { "category_id": 1, "product_id": 3, "product_name": "COTTON VEST", "retail_price": 2.95 } ], "meta": { "count": 8, "page": 1, "per_page": 3, "total_pages": 3 } }Retrieve page 1 of
productssorted withproduct_nameindescendingorder.$ curl -i -u john_doe:EXAMPLE_PASSWORD -X GET "localhost:8081/api/v1/products?page=1&per_page=3&sort=-product_name"Output.
HTTP/1.1 200 OK Content-Type: application/json Date: Mon, 01 Nov 2021 11:25:46 GMT Content-Length: 487 { "data": [ { "category_id": 2, "product_id": 4, "product_name": "WIRELESS MOUSE", "retail_price": 19.45 }, { "category_id": 1, "product_id": 1, "product_name": "WINTER JACKET", "retail_price": 58.3 }, { "category_id": 1, "product_id": 2, "product_name": "LEATHER BELT", "retail_price": 14.95 } ], "meta": { "count": 8, "page": 1, "per_page": 3, "total_pages": 3 } }Add a new item into the products resource using the HTTP
POSTmethod.$ curl -i -u john_doe:EXAMPLE_PASSWORD -X POST localhost:8081/api/v1/products -H "Content-Type: application/json" -d '{"product_name": "WIRELESS KEYBOARD", "category_id": 2, "retail_price": 55.69}' Output. HTTP/1.1 201 Created Content-Type: application/json Date: Mon, 01 Nov 2021 11:33:20 GMT Content-Length: 130 { "data": { "category_id": 2, "product_id": 9, "product_name": "WIRELESS KEYBOARD", "retail_price": 55.69 } }Update the new record with a
product_idof 9 using the HTTPPUTmethod.$ curl -i -u john_doe:EXAMPLE_PASSWORD -X PUT localhost:8081/api/v1/products/9 -H "Content-Type: application/json" -d '{"product_name": "WIRELESS USB KEYBOARD", "category_id": 2, "retail_price": 50.99}'Output.
HTTP/1.1 200 OK Content-Type: application/json Date: Mon, 01 Nov 2021 11:34:45 GMT Content-Length: 134 { "data": { "category_id": 2, "product_id": 9, "product_name": "WIRELESS USB KEYBOARD", "retail_price": 50.99 } }Delete the new product using the HTTP
DELETEmethod.$ curl -i -u john_doe:EXAMPLE_PASSWORD -X DELETE "localhost:8081/api/v1/products/9"Output.
HTTP/1.1 200 OK Content-Type: application/json Date: Mon, 01 Nov 2021 11:36:14 GMT Content-Length: 24 { "data": "Success" }Attempt to retrieve the product you've just deleted.
$ curl -i -u john_doe:EXAMPLE_PASSWORD -X GET "localhost:8081/api/v1/products/9"Output.
HTTP/1.1 404 Not Found Content-Type: application/json Date: Mon, 01 Nov 2021 11:37:16 GMT Content-Length: 132 { "error": { "error_code": "404", "message": "Record not found", "resource": "products", "status_code": 404 } }Retrieve all
categoriesfrom the API.$ curl -i -u john_doe:EXAMPLE_PASSWORD -X GET "localhost:8081/api/v1/categories"Output.
HTTP/1.1 200 OK Content-Type: application/json Date: Mon, 01 Nov 2021 11:01:12 GMT Content-Length: 478 { "data": [ { "category_id": 1, "category_name": "APPAREL", "description": "Stores different clothing" }, { "category_id": 2, "category_name": "ELECTRONICS", "description": "Stores different electronics" }, { "category_id": 3, "category_name": "CAR ACCESSORIES", "description": "Stores car DIY items" } ], "meta": { "count": 3, "page": 1, "per_page": -1, "total_pages": 1 } }Retrieve a single category with a
category_idof 1.$ curl -i -u john_doe:EXAMPLE_PASSWORD -X GET "localhost:8081/api/v1/categories/1"Output.
HTTP/1.1 200 OK Content-Type: application/json Date: Mon, 01 Nov 2021 11:01:45 GMT Content-Length: 121 { "data": { "category_id": 1, "category_name": "APPAREL", "description": "Stores different clothing" } }Add a new record to the
categoriesresource using the HTTP POST method.$ curl -i -u john_doe:EXAMPLE_PASSWORD -X POST localhost:8081/api/v1/categories -H "Content-Type: application/json" -d '{"category_name": "FURNITURES", "description": "This category holds all furnitures in the store."}'Output.
HTTP/1.1 201 Created Content-Type: application/json Date: Mon, 01 Nov 2021 11:03:56 GMT Content-Length: 147 { "data": { "category_id": 4, "category_name":