Building a Go Microservice with CI/CD

  1. Create a new router,
  2. Define a route to use the same handler that the main app uses (showIndexPage),
  3. Create a new request to access this route,
  4. Create a function that processes the response to test the HTTP code and HTML, and
  5. Call testHTTPResponse() with this new function to complete the test.

Creating the Route Handler

1. Fetches the list of articles

articles := getAllArticles()
c.HTML( // Set the HTTP status to 200 (OK) http.StatusOK, // Use the index.html template "index.html", // Pass the data that the page uses gin.H{ "title": "Home Page", "payload": articles, }, )
// handlers.article.go package main import ( "net/http" "github.com/gin-gonic/gin" ) func showIndexPage(c *gin.Context) { articles := getAllArticles() // Call the HTML method of the Context to render a template c.HTML( // Set the HTTP status to 200 (OK) http.StatusOK, // Use the index.html template "index.html", // Pass the data that the page uses gin.H{ "title": "Home Page", "payload": articles, }, ) }
├── common_test.go ├── handlers.article.go ├── handlers.article_test.go ├── models.article.go └── models.article_test.go

Displaying a Single Article

In the last section, while we displayed a list of articles, the links to the articles didn’t work. In this section, we’ll add handlers and templates to display an article when it is selected.

Setting Up the Route

We can set up a new route to handle requests for a single article in the same manner as in the previous route. However, we need to account for the fact that while the handler for all articles would be the same, the URL for each article would be different. Gin allows us to handle such conditions by defining route parameters as follows:

router.GET("/article/view/:article_id", getArticle)
func main() { router := gin.Default() router.LoadHTMLGlob("templates/*") // Handle Index router.GET("/", showIndexPage) // Handle GET requests at /article/view/some_article_id router.GET("/article/view/:article_id", getArticle) router.Run() } . . .

Creating the View Templates

We need to create a new template at templates/article.html to display the content of a single article. This can be created in a manner similar to the index.html template. However, instead of the payload variable containing the list of articles, in this case it will contain a single article.

<!--article.html--> <!--Embed the header.html template at this location--> {{ template "header.html" .}} <!--Display the title of the article--> <h1>{{.payload.Title}}</h1> <!--Display the content of the article--> <p>{{.payload.Content}}</p> <!--Embed the footer.html template at this location--> {{ template "footer.html" .}}

Specifying the Requirement for the Go Microservice Router

The test for the handler of this route will check for the following conditions:

  • The handler responds with an HTTP status code of 200,
  • The returned HTML contains a title tag containing the title of the article that was fetched.

Creating the Route Handler

1. Extracts the ID of the article to display

c.Param("article_id")
article, err := getArticleByID(articleID)
// models.article.go package main import ( "errors" ) type article struct { ID int `json:"id"` Title string `json:"title"` Content string `json:"content"` } // For this demo, we're storing the article list in memory // In a real application, this list will most likely be fetched // from a database or from static files var articleList = []article{ article{ID: 1, Title: "Article 1", Content: "Article 1 body"}, article{ID: 2, Title: "Article 2", Content: "Article 2 body"}, } // Return a list of all the articles func getAllArticles() []article { return articleList } func getArticleByID(id int) (*article, error) { for _, a := range articleList { if a.ID == id { return &a, nil } } return nil, errors.New("Article not found") }
c.HTML( // Set the HTTP status to 200 (OK) http.StatusOK, // Use the article.html template "article.html", // Pass the data that the page uses gin.H{ "title": article.Title, "payload": article, }, )
// handlers.article.go package main import ( "net/http" "strconv" "github.com/gin-gonic/gin" ) func showIndexPage(c *gin.Context) { articles := getAllArticles() // Call the HTML method of the Context to render a template c.HTML( // Set the HTTP status to 200 (OK) http.StatusOK, // Use the index.html template "index.html", // Pass the data that the page uses gin.H{ "title": "Home Page", "payload": articles, }, ) } func getArticle(c *gin.Context) { // Check if the article ID is valid if articleID, err := strconv.Atoi(c.Param("article_id")); err == nil { // Check if the article exists if article, err := getArticleByID(articleID); err == nil { // Call the HTML method of the Context to render a template c.HTML( // Set the HTTP status to 200 (OK) http.StatusOK, // Use the index.html template "article.html", // Pass the data that the page uses gin.H{ "title": article.Title, "payload": article, }, ) } else { // If the article is not found, abort with an error c.AbortWithError(http.StatusNotFound, err) } } else { // If an invalid article ID is specified in the URL, abort with an error c.AbortWithStatus(http.StatusNotFound) } }
└── templates └── article.html

Responding With JSON/XML

In this section, we will refactor the application a bit so that, depending on the request headers, our application can respond in HTML, JSON or XML format.

Creating a Reusable Function

So far, we’ve been using the HTML method of Gin's context to render directly from route handlers. This is fine when we always want to render HTML. However, if we want to change the format of the response based on the request, we should refactor this part out into a single function that takes care of the rendering. By doing this, we can let the route handler focus on validation and data fetching.

// c is the Gin Context c.Request.Header.Get("Accept")
  • If this is set to application/json, the function will render JSON,
  • If this is set to application/xml, the function will render XML, and
  • If this is set to anything else or is empty, the function will render HTML.
// Render one of HTML, JSON or CSV based on the 'Accept' header of the request // If the header doesn't specify this, HTML is rendered, provided that // the template name is present func render(c *gin.Context, data gin.H, templateName string) { switch c.Request.Header.Get("Accept") { case "application/json": // Respond with JSON c.JSON(http.StatusOK, data["payload"]) case "application/xml": // Respond with XML c.XML(http.StatusOK, data["payload"]) default: // Respond with HTML c.HTML(http.StatusOK, templateName, data) } }

Modifying the Requirement for the Route Handlers With a Unit Test

Since we are now expecting JSON and XML responses if the respective headers are set, we should add tests to the handlers.article_test.go file to test these conditions. We will add tests to:

  1. Test that the application returns a JSON list of articles when the Accept header is set to application/json
  2. Test the application returns an article in XML format when the Accept header is set to application/xml

Updating the Route Handlers

The route handlers don’t really need to change much as the logic for rendering in any format is pretty much the same. All that needs to be done is use the render function instead of rendering using the c.HTML methods.

func showIndexPage(c *gin.Context) { articles := getAllArticles() // Call the HTML method of the Context to render a template c.HTML( // Set the HTTP status to 200 (OK) http.StatusOK, // Use the index.html template "index.html", // Pass the data that the page uses gin.H{ "title": "Home Page", "payload": articles, }, ) }
func showIndexPage(c *gin.Context) { articles := getAllArticles() // Call the render function with the name of the template to render render(c, gin.H{ "title": "Home Page", "payload": articles}, "index.html") }
curl -X GET -H "Accept: application/json" http://localhost:8080/
[{"id":1,"title":"Article 1","content":"Article 1 body"},{"id":2,"title":"Article 2","content":"Article 2 body"}]
curl -X GET -H "Accept: application/xml" http://localhost:8080/article/view/1
<article><ID>1</ID><Title>Article 1</Title><Content>Article 1 body</Content></article>

Testing the Application

Since we’ve been using tests to create specifications for our route handlers and models, we should constantly be running them to ensure that the functions work as expected. Let’s now run the tests that we have written and see the results. In your project directory, execute the following command:

=== RUN TestShowIndexPageUnauthenticated [GIN] 2022/05/11 - 11:33:20 | 200 | 429.084µs | | GET "/" --- PASS: TestShowIndexPageUnauthenticated (0.00s) === RUN TestGetAllArticles --- PASS: TestGetAllArticles (0.00s) PASS ok github.com/tomfern/semaphore-demo-go-gin	0.704s

Continuous Integration for Go on Semaphore

Continuous Integration (CI) can test and build the application for us in a fast and clean environment. When we are ready to publish, Continuous Delivery (CD) can make the releases, secure in the knowledge that the code passed all our tests.

$ git init $ git remote add YOUR_REPOSITORY_URL $ git add -A $ git commit -m "initial commit" $ git push origin main

Add Semaphore to Your Project

Adding CI/CD to your project is completely free and takes only a few minutes:

  1. Sign up for a free Semaphore account.
  2. Click on Create new on the top navigation bar.
sem-version go 1.18

Improving the Pipeline

The starter CI pipeline should work seamlessly without any additional setup. We can, however, make some improvements:

  • Dependencies have to be downloaded every time. We can use a cache to keep them and speed up things.
  • Testing and building are on the same job. We should split it in different jobs so it’s easier for us to later add more tests.
  • Pipeline: A pipeline has a specific objective, e.g. testing. Pipelines are made of blocks that are executed from left to right in an agent.
  • Agent: The agent is the virtual machine that powers the pipeline. We have three machine types to choose from. The machine runs an optimized Ubuntu 20.04 image with build tools for many languages.
  • Block: blocks group jobs that can be executed in parallel. Jobs in a block usually have similar commands and configurations. Once all jobs in a block complete, the next block begins.
  • Job: jobs define the commands that do the work. They inherit their configuration from their parent block.
  1. Click on the first block and change its name to: “Install dependencies”.
  2. Below you’ll find the job, change its name to “Install” and type the following commands in the box:
sem-version go 1.18 export GO111MODULE=on export GOPATH=~/go export PATH=/home/semaphore/go/bin:$PATH checkout cache restore go mod vendor cache store
  • sem-version: a Semaphore built-in command to manage programming language versions. Semaphore supports most Go versions.
  • checkout: another built-in command, checkout clones the repository and changes the current directory.
  • go mod vendor: this a Go command that downloads the dependencies into the vendor directory, so they can be cached.
  • cache: the cache commands provides read and write access to Semaphore’s cache, a project-wide storage for the jobs.

Testing with Semaphore

We expect our CI pipeline to be able to test the project and build a binary. We’ll add two more blocks for that:

  1. Click on Edit Workflow
  2. Use the +Add Block dotted line button to create a new block. Name the block “Test”
  3. Open the Prologue section and type the following commands. The commands are executed before all jobs in the block:
sem-version go 1.18 export GO111MODULE=on export GOPATH=~/go export PATH=/home/semaphore/go/bin:$PATH checkout cache restore go mod vendor
  1. Add the third block, let’s call it “Build”
  2. Repeat the Prologue and Environment Variables steps as before.
  3. Set the name of the job to “Build”
  4. Type the following commands in the box:
go build -v -o go-gin-app artifact push project --force go-gin-app

Conclusion

In this tutorial, we created a new web application using Gin and gradually added more functionality. We used tests to build robust route handlers and saw how we can reuse the same code to render a response in multiple formats with minimal effort.

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Semaphore

Semaphore

Supporting developers with insights and tutorials on delivering good software. · https://semaphoreci.com