/

Tutorial: Building a REST API with Go and PostgreSQL

Tutorial: Building a REST API with Go and PostgreSQL

In this tutorial, I will demonstrate how to create a JSON API using Go. The API will serve data from a PostgreSQL database for a Vue.js application.

Table of Contents

Introduction to the problem

The goal of this tutorial is to develop a single-page application using Vue.js. The application will display a list of Git repositories, and upon clicking on a repository, it will show additional details. The API we’ll create will be read-only and have two endpoints:

  • /api/index: Lists all the repositories
  • /api/repo/:owner/:name: Shows the details of a specific repository identified by the owner and name

It’s important to note that the owner and name parameters follow the structure of a typical GitHub URL (e.g., github.com/owner/name).

To achieve this, we will connect our Go application to an existing PostgreSQL database and serve responses based on the parameters passed in the request.

The existing database structure

The application relies on multiple tables in the PostgreSQL database. These tables include:

  • repositories: Contains information about repositories, such as the number of total stars
  • repositories_weekly_data: Stores weekly activity information for each repository
  • repositories_historic_data: Stores aggregated commit and star information per month
  • repositories_timelines: Stores relevant events associated with each repository, such as milestone achievements or creation dates

Simple HTTP response handler

Let’s start by writing a simple HTTP server that handles two routes:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package main

import (
"log"
"net/http"
)

func main() {
http.HandleFunc("/api/index", indexHandler)
http.HandleFunc("/api/repo/", repoHandler)
log.Fatal(http.ListenAndServe("localhost:8000", nil))
}

func indexHandler(w http.ResponseWriter, r *http.Request) {
//...
}

func repoHandler(w http.ResponseWriter, r *http.Request) {
//...
}

This code sets up a simple server that can handle parallel processing of requests.

Connecting to PostgreSQL

Next, we need to connect our Go application to the PostgreSQL database. For this, we will use the database/sql package without any additional ORM or external libraries. The PostgreSQL connection details will be retrieved from environment variables.

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
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
package main

import (
"database/sql"
"fmt"
"log"
"net/http"
"os"

_ "github.com/lib/pq"
)

var db *sql.DB

const (
dbhost = "DBHOST"
dbport = "DBPORT"
dbuser = "DBUSER"
dbpass = "DBPASS"
dbname = "DBNAME"
)

func main() {
initDb()
defer db.Close()
http.HandleFunc("/api/index", indexHandler)
http.HandleFunc("/api/repo/", repoHandler)
log.Fatal(http.ListenAndServe("localhost:8000", nil))
}

func indexHandler(w http.ResponseWriter, r *http.Request) {
//...
}

func repoHandler(w http.ResponseWriter, r *http.Request) {
//...
}

func initDb() {
config := dbConfig()
var err error
psqlInfo := fmt.Sprintf("host=%s port=%s user=%s "+
"password=%s dbname=%s sslmode=disable",
config[dbhost], config[dbport],
config[dbuser], config[dbpass], config[dbname])

db, err = sql.Open("postgres", psqlInfo)
if err != nil {
panic(err)
}
err = db.Ping()
if err != nil {
panic(err)
}
fmt.Println("Successfully connected!")
}

func dbConfig() map[string]string {
conf := make(map[string]string)
host, ok := os.LookupEnv(dbhost)
if !ok {
panic("DBHOST environment variable required but not set")
}
port, ok := os.LookupEnv(dbport)
if !ok {
panic("DBPORT environment variable required but not set")
}
user, ok := os.LookupEnv(dbuser)
if !ok {
panic("DBUSER environment variable required but not set")
}
password, ok := os.LookupEnv(dbpass)
if !ok {
panic("DBPASS environment variable required but not set")
}
name, ok := os.LookupEnv(dbname)
if !ok {
panic("DBNAME environment variable required but not set")
}
conf[dbhost] = host
conf[dbport] = port
conf[dbuser] = user
conf[dbpass] = password
conf[dbname] = name
return conf
}

This code connects to the PostgreSQL database using the provided environment variables for the connection details. It also checks if the necessary environment variables are set and panics if any are missing. The connection is opened and kept active until the program exits.

Moving handlers to their own file

To keep the code organized, we’ll move the HTTP request handlers to separate files. Here are the updated files:

api/api.go

1
2
3
4
5
6
//...
import "github.com/flaviocopes/gitometer/api/handlers"
//...
http.HandleFunc("/api/index", handlers.Index)
http.HandleFunc("/api/repo/", handlers.Repo)
//...

api/index.go

1
2
3
4
5
6
7
8
9
package handlers

import (
"net/http"
)

func indexHandler(w http.ResponseWriter, r *http.Request) {
//....
}

api/repo.go

1
2
3
4
5
6
7
8
9
package handlers

import (
"net/http"
)

func repoHandler(w http.ResponseWriter, r *http.Request) {
//....
}

Implementing the /api/index endpoint

The /api/index endpoint will list all the repositories present in the database. The response will include the repository’s name, owner, and the number of stars it has on GitHub. To fetch this data, we will query the repositories table and marshal the result as JSON.

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
61
62
63
64
65
66
67
68
69
package main

import (
"encoding/json"
"fmt"
"net/http"
)

type repositorySummary struct {
ID int
Name string
Owner string
TotalStars int
}

type repositories struct {
Repositories []repositorySummary
}

func indexHandler(w http.ResponseWriter, req *http.Request) {
repos := repositories{}

err := queryRepos(&repos)
if err != nil {
http.Error(w, err.Error(), 500)
return
}

out, err := json.Marshal(repos)
if err != nil {
http.Error(w, err.Error(), 500)
return
}

fmt.Fprintf(w, string(out))
}

func queryRepos(repos *repositories) error {
rows, err := db.Query(`
SELECT
id,
repository_owner,
repository_name,
total_stars
FROM repositories
ORDER BY total_stars DESC`)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
repo := repositorySummary{}
err = rows.Scan(
&repo.ID,
&repo.Owner,
&repo.Name,
&repo.TotalStars,
)
if err != nil {
return err
}
repos.Repositories = append(repos.Repositories, repo)
}
err = rows.Err()
if err != nil {
return err
}
return nil
}

In this code, we define two structs to hold the repository data. The indexHandler function queries the repositories table, fills the repositories struct, and then marshals it as JSON to be sent as the API response.

Implementing the /api/repo/ endpoint

The /api/repo/:owner/:name endpoint will return the details of a specific repository specified by the owner and name parameters. Similar to the /api/index endpoint, we will query the database for the repository details and its associated data using the provided repository ID.

Here’s the complete code for the /api/repo/ endpoint:

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
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
package main

import (
"database/sql"
"encoding/json"
"fmt"
"net/http"
"strings"
)

type week struct {
ID int
RepositoryID int
WeekNumber int
Year int
CreatedOn string
IssuesClosed int
IssuesOpened int
Stars int
Commits int
WeekStart string
WeekEnd string
PrOpened int
PrMerged int
PrClosed int
}

type timeline struct {
ID int
RepositoryID int
Title string
Description string
Emoji string
Date string
}

type repository struct {
ID int
Name string
Owner string
RepoAge int
Initialized bool
CommitsPerMonth string
StarsPerMonth string
TotalStars int
}

type owner struct {
ID int
Name string
Description string
Avatar string
GitHubID string
AddedBy string
Enabled bool
InstallationID string
RepositorySelection string
}

type repoData struct {
MonthlyData monthlyData
WeeklyData []week
Years map[int]bool
Timeline []timeline
Repository repository
Owner owner
}

type monthlyData struct {
CommitsPerMonth string
StarsPerMonth string
}

type errRepoNotInitialized string

func (e errRepoNotInitialized) Error() string {
return string(e)
}

type errRepoNotFound string

func (e errRepoNotFound) Error() string {
return string(e)
}

func parseParams(req *http.Request, prefix string, num int) ([]string, error) {
url := strings.TrimPrefix(req.URL.Path, prefix)
params := strings.Split(url, "/")
if len(params) != num || len(params[0]) == 0 || len(params[1]) == 0 {
return nil, fmt.Errorf("Bad format. Expecting exactly %d params", num)
}
return params, nil
}

func repoHandler(w http.ResponseWriter, req *http.Request) {
repo := repository{}
params, err := parseParams(req, "/api/repo/", 2)
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
repo.Owner = params[0]
repo.Name = params[1]

data, err := queryRepo(&repo)
if err != nil {
switch err.(type) {
case errRepoNotFound:
http.Error(w, err.Error(), 404)
case errRepoNotInitialized:
http.Error(w, err.Error(), 401)
default:
http.Error(w, err.Error(), 500)
}
return
}

out, err := json.Marshal(data)
if err != nil {
http.Error(w, err.Error(), 500)
return
}

fmt.Fprintf(w, string(out))
}

func queryRepo(repo *repository) (*repoData, error) {
err := fetchRepo(repo)
if err != nil {
return nil, err
}

return fetchData(repo)
}

func fetchData(repo *repository) (*repoData, error) {
data := repoData{}
err := fetchMonthlyData(repo, &data)
if err != nil {
return nil, err
}
err = fetchWeeklyData(repo, &data)
if err != nil {
return nil, err
}
err = fetchYearlyData(repo, &data)
if err != nil {
return nil, err
}
err = fetchTimelineData(repo, &data)
if err != nil {
return nil, err
}
err = fetchOwnerData(repo, &data)
if err != nil {
return nil, err
}
return &data, nil
}

func fetchRepo(repo *repository) error {
if len(repo.Name) == 0 {
return fmt.Errorf("Repository name not correctly set")
}
if len(repo.Owner) == 0 {
return fmt.Errorf("Repository owner not correctly set")
}
sqlStatement := `
SELECT
id,
initialized,
repository_created_months_ago
FROM repositories
WHERE repository_owner=$1 and repository_name=$2
LIMIT 1;`
row := db.QueryRow(sqlStatement, repo.Owner, repo.Name)
err := row.Scan(&repo.ID, &repo.Initialized, &repo.RepoAge)
if err != nil {
switch err {
case sql.ErrNoRows:
return errRepoNotFound("Repository not found")
default:
return err
}
}
if !repo.Initialized {
return errRepoNotInitialized("Repository not initialized")
}
if repo.RepoAge < 3 {
return errRepoNotInitialized("Repository not initialized")
}
return nil
}

func fetchOwnerData(repo *repository, data *repoData) error {
if len(repo.Owner) == 0 {
return fmt.Errorf("Repository owner not correctly set")
}
sqlStatement := `
SELECT
id,
name,
COALESCE(description, ''),
COALESCE(avatar_url, ''),
COALESCE(github_id, ''),
added_by,
enabled,
COALESCE(installation_id, ''),
repository_selection
FROM organizations
WHERE name=$1
ORDER BY id DESC LIMIT 1;`
row := db.QueryRow(sqlStatement, repo.Owner)
err := row.Scan(&data.Owner.ID,
&data.Owner.Name,
&data.Owner.Description,
&data.Owner.Avatar,
&data.Owner.GitHubID,
&data.Owner.AddedBy,
&data.Owner.Enabled,
&data.Owner.InstallationID,
&data.Owner.RepositorySelection)
if err != nil {
return err
}
return nil
}

func fetchMonthlyData(repo *repository, data *repoData) error {
if repo.ID == 0 {
return fmt.Errorf("Repository ID not correctly set")
}
data.MonthlyData = monthlyData{}
sqlStatement := `
SELECT
commits_per_month,
stars_per_month
FROM repositories_historic_data
WHERE repository_id=$1
ORDER BY id DESC LIMIT 1;`
row := db.QueryRow(sqlStatement, repo.ID)
err := row.Scan(
&data.MonthlyData.CommitsPerMonth,
&data.MonthlyData.StarsPerMonth,
)
if err != nil {
return err
}

return nil
}

func fetchWeeklyData(repo *repository, data *repoData) error {
if repo.ID == 0 {
return fmt.Errorf("Repository ID not correctly set")
}
rows, err := db.Query(`
SELECT
id,
repository_id,
week_number,
year,
created_on,
issues_closed,
issues_opened,
stars,
commits,
week_start,
week_end,
pr_opened,
pr_merged,
pr_closed
FROM repositories_weekly_data
WHERE repository_id=$1
ORDER BY id ASC`, repo.ID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
week := week{}
err = rows.Scan(
&week.ID,
&week.RepositoryID,
&week.WeekNumber,
&week.Year,
&week.CreatedOn,
&week.IssuesClosed,
&week.IssuesOpened,
&week.Stars,
&week.Commits,
&week.WeekStart,
&week.WeekEnd,
&week.PrOpened,
&week.PrMerged,
&week.PrClosed,
)
if err != nil {
return err
}
data.WeeklyData = append(data.WeeklyData, week)
}
err = rows.Err()
if err != nil {
return err
}
return nil
}

func fetchYearlyData(repo *repository, data *repoData) error {
if data.WeeklyData == nil {
return fmt.Errorf("Repository weekly data not correctly set")
}
data.Years = make(map[int]bool)
for i := 0; i < len(data.WeeklyData); i++ {
year := data.WeeklyData[i].Year
data.Years[year] = true
}
return nil
}

func fetchTimelineData(repo *repository, data *repoData) error {
if repo.ID == 0 {
return fmt.Errorf("Repository ID not correctly set")
}
rows, err := db.Query(`
SELECT
id,
repository_id,
title,
description,
emoji,
date
FROM repositories_timelines
WHERE repository_id=$1
ORDER BY date ASC`, repo.ID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
timeline := timeline{}
err = rows.Scan(
&timeline.ID,
&timeline.RepositoryID,
&timeline.Title,
&timeline.Description,
&timeline.Emoji,
&timeline.Date,
)
if err != nil {
return err
}
data.Timeline = append(data.Timeline, timeline)
}
err = rows.Err()
if err != nil {
return err
}
return nil
}

This endpoint fetches various data associated with the specified repository, such as weekly activity, monthly statistics, timeline events, owner information, and more. The fetched data is then merged into the repoData struct, which is then marshaled as JSON.

Is this proper JSON?

In the JSON responses we generate, the property names of the struct fields are in UpperCamelCase, which differs from the commonly used camelCase or snake_case conventions. However, this is still valid JSON, and there is no standard naming convention for JSON keys.

If you prefer different naming conventions, like camelCase or snake_case, you can use struct tags to customize the field names when marshaling JSON. Simply add the json:"proper_field_name" tag to each struct field. For example:

1
2
3
4
5
6
7
8
type repoData struct {
MonthlyData monthlyData `json:"monthlyData"`
WeeklyData []week `json:"weeklyData"`
Years map[int]bool `json:"years"`
Timeline []timeline `json:"timeline"`
Repository repository `json:"repository"`
Owner owner `json:"owner"`
}

By using struct tags, you can customize the JSON field names to fit your preferred convention.

Wrapping up

In this tutorial, we covered the basics of creating a JSON API using Go and PostgreSQL. Although this is just a starting point, you now have the foundation to build upon and expand the functionality of your API. Some potential next steps include testing the API, adding authentication, implementing rate limiting, and introducing API version control.

Tags: Go, Golang, REST API, PostgreSQL