Hello! Welcome to the sixth module of our course, "Interacting with Web Services and APIs."
In the last module, we built a concurrent worker pool, a powerful pattern for running I/O-bound tasks in parallel. One of the most common I/O-bound tasks in DevOps is interacting with web services and REST APIs—from cloud providers like AWS, to CI/CD platforms like GitLab, to monitoring systems like Prometheus.
This lesson kicks off our journey into networking by focusing on the fundamental building block: making HTTP requests. You will learn how to use Go's standard net/http package to build a client that can fetch data with GET requests and send data with POST requests. Mastering this is the first step toward building robust automation tools that can communicate with any HTTP-based service.
1. The Foundation: A Robust HTTP Client
Go's net/http package provides everything you need to work with HTTP. A central type is the http.Client, which is responsible for sending requests and managing state like cookies and persistent connections.
While you can make simple requests with package-level functions like http.Get(), it is a strong best practice to create your own http.Client instance. Why? The default client used by these helper functions has no timeout. This means a request to an unresponsive server could cause your program to hang indefinitely, which is a significant risk for any automation script.
Creating a custom client is straightforward and allows you to configure crucial behavior, most importantly, a request timeout.
Making HTTP Requests with the HTTP Client in Go
The video "Making HTTP Requests with the HTTP Client in Go" by Code & Learn starts by explaining exactly why you should create a custom client. Let's start there.
Watch from the beginning to 01:41 to understand the motivation behind using a custom client. Then, skip to 02:48 and watch until 03:22 to see how a custom http.Client is instantiated with a specific Timeout.
The key takeaway is this piece of code:
client := &http.Client{
Timeout: 10 * time.Second,
}
This simple configuration ensures that no single HTTP request will block your application for more than 10 seconds.
2. Fetching Data with GET Requests
A GET request is used to retrieve data from a server. This is what your browser does when you visit a webpage, and it's what your code will do to fetch the status of a service or pull configuration from an API endpoint.
The process involves a few clear steps:
- Create the Request: Construct an
http.Requestobject specifying the method ("GET"), the URL, and no body. Usinghttp.NewRequestgives you more flexibility than the simplerhttp.Gethelper. - Execute the Request: Use your custom client's
Do()method to send the request. - Check for Errors: The
Do()method returns a response and an error. Always check the error first (e.g., for network issues). - Close the Response Body: The response body is an
io.ReadCloserthat must be closed to release the underlying network connection. Thedeferstatement is perfect for this:defer resp.Body.Close(). - Check the Status Code: A successful network round-trip doesn't mean the request was successful. You must check the HTTP status code (e.g.,
resp.StatusCode != http.StatusOK). - Process the Body: If the status is OK, you can read the response body, for example, by decoding it from JSON into a Go struct.
Making HTTP Requests with the HTTP Client in Go
Let's continue with the Code & Learn video, which provides a complete walkthrough of making a GET request to a public API, decoding the JSON response, and handling status codes.
Watch from 03:22 to 05:27 and then from 06:44 to 08:15. This covers the entire lifecycle of a GET request: (03:22) Creating the request with http.NewRequestWithContext. (04:10) Executing with client.Do() and using defer resp.Body.Close(). (04:47) Decoding the JSON response body. (06:44) Checking the resp.StatusCode for non-2xx responses, which is a crucial error handling step.
Here's a summary of the code structure for a GET request:
package main
import (
"fmt"
"io"
"net/http"
"os"
"time"
)
func main() {
// 1. Create a custom client with a timeout
client := &http.Client{
Timeout: 10 * time.Second,
}
// 2. Create the request
req, err := http.NewRequest("GET", "https://httpbin.org/get", nil)
if err != nil {
fmt.Printf("Error creating request: %s\n", err)
os.Exit(1)
}
// 3. Execute the request
resp, err := client.Do(req)
if err != nil {
fmt.Printf("Error making http request: %s\n", err)
os.Exit(1)
}
// 4. IMPORTANT: Defer closing the body to prevent resource leaks
defer resp.Body.Close()
// 5. Check the status code
if resp.StatusCode != http.StatusOK {
fmt.Printf("Error: received status code %d\n", resp.StatusCode)
os.Exit(1)
}
fmt.Println("Client: got response!")
fmt.Printf("Client: status code: %d\n", resp.StatusCode)
// 6. Read and print the body
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Printf("Error reading response body: %s\n", err)
os.Exit(1)
}
fmt.Printf("Client: response body: %s\n", string(bodyBytes))
}
3. Sending Data with POST Requests
A POST request is used to send data to a server, typically to create or update a resource. For example, you might POST a JSON payload to a GitLab API endpoint to trigger a new CI/CD pipeline.
The process is very similar to a GET request, with two key additions:
- Provide a Request Body: The data you send must be an
io.Reader. For JSON, this usually means marshaling a Go struct into a[]byteslice and then wrapping it with abytes.NewReaderorbytes.NewBuffer. - Set the
Content-TypeHeader: You must tell the server what kind of data you're sending. For JSON, this header's value is"application/json".
How To Make HTTP Requests in Go
The article "How To Make HTTP Requests in Go" from DigitalOcean provides excellent, step-by-step instructions for constructing and sending a POST request.
Please read two sections from this article: "Sending a POST Request": Focus on how a []byte slice containing JSON is wrapped in a bytes.NewReader to create an io.Reader for the request body. Note how this reader is passed to http.NewRequest. "Customizing an HTTP Request": Pay close attention to the code snippet showing how to set the Content-Type header using req.Header.Set("Content-Type", "application/json"). This is a critical and often-forgotten step. You can ignore the parts about setting up a local server; the core concepts of building the client-side request are what matter here.
Combining these concepts, the structure for a POST request with a JSON body looks like this:
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
)
// A struct to model our JSON data
type JobPayload struct {
Name string `json:"name"`
Branch string `json:"branch"`
}
func main() {
client := &http.Client{
Timeout: 10 * time.Second,
}
// 1. Create the data and marshal it to JSON
payload := JobPayload{Name: "deploy-production", Branch: "main"}
payloadBytes, err := json.Marshal(payload)
if err != nil {
// handle error
}
// Create an io.Reader from the JSON byte slice
bodyReader := bytes.NewReader(payloadBytes)
// 2. Create the request, now with a method and body
req, err := http.NewRequest("POST", "https://httpbin.org/post", bodyReader)
if err != nil {
// handle error
}
// 3. Set the crucial Content-Type header
req.Header.Set("Content-Type", "application/json")
// Execute the request and process the response (same as GET)
resp, err := client.Do(req)
if err != nil {
// handle error
}
defer resp.Body.Close()
fmt.Printf("Status: %d\n", resp.StatusCode)
respBody, _ := io.ReadAll(resp.Body)
fmt.Printf("Response Body: %s\n", string(respBody))
}
Test your understanding!
You need to write a function that reports the status of a deployment to a monitoring service. The function should send an HTTP POST request to the URL https://example.com/api/report.
The request must contain a JSON body with the following structure:
{
"service": "api-gateway",
"status": "success"
}
Your task is to complete the reportStatus function below. You don't need to write the main function or handle the response, just construct and send the request correctly.
package main
import (
"bytes"
"encoding/json"
"net/http"
"time"
)
type StatusReport struct {
Service string `json:"service"`
Status string `json:"status"`
}
// reportStatus sends a deployment status to a monitoring service.
func reportStatus(serviceName, status string) (*http.Response, error) {
client := &http.Client{
Timeout: 5 * time.Second,
}
// --- YOUR CODE GOES HERE ---
// 1. Create a StatusReport instance with the provided arguments.
// 2. Marshal it into JSON bytes.
// 3. Create an http.Request with the "POST" method, URL, and JSON body.
// 4. Set the "Content-Type" header to "application/json".
// 5. Execute the request with client.Do() and return its response and error.
// --- END OF YOUR CODE ---
}
Show answer
Here's the completed reportStatus function:
// reportStatus sends a deployment status to a monitoring service.
func reportStatus(serviceName, status string) (*http.Response, error) {
client := &http.Client{
Timeout: 5 * time.Second,
}
// 1. Create a StatusReport instance.
report := StatusReport{
Service: serviceName,
Status: status,
}
// 2. Marshal it into JSON bytes.
jsonData, err := json.Marshal(report)
if err != nil {
return nil, err
}
// 3. Create an http.Request with the "POST" method, URL, and JSON body.
req, err := http.NewRequest("POST", "https://example.com/api/report", bytes.NewBuffer(jsonData))
if err != nil {
return nil, err
}
// 4. Set the "Content-Type" header.
req.Header.Set("Content-Type", "application/json")
// 5. Execute the request and return the result.
return client.Do(req)
}
This solution correctly follows all the steps required for a POST request: creating and marshaling the data, wrapping it in an io.Reader (bytes.NewBuffer works just like bytes.NewReader here), creating the request, setting the header, and finally executing it.
Conclusion
In this lesson, you've learned the essential mechanics of interacting with HTTP services in Go. This skill is a cornerstone for building DevOps automation, allowing your tools to communicate with the vast ecosystem of web-based APIs.
Key Takeaways:
- Always create a custom
http.Clientto set aTimeoutand avoid hanging requests. - Use
http.NewRequestand a client'sDo()method to have full control over the request. - For GET requests, the process is: create request, execute, check errors and status,
deferbody closure, and read the body. - For POST requests, you must also provide an
io.Readeras the request body and set theContent-Typeheader appropriately. - Always remember
defer resp.Body.Close()to prevent resource leaks.
Next Up
We've seen how to get a response body, but often that body is complex JSON. In our next lesson, we will dive deeper into parsing JSON data from API responses into Go structs. You'll learn how to handle nested objects, arrays, and fields with different names, enabling you to work with real-world API data effectively.