지난 포스트


웹 프로그래밍 with Golang 1 - Hello, World!

웹 프로그래밍 with Golang 2 - 템플릿 문법

웹 프로그래밍 with Golang 3 - 정적 파일

웹 프로그래밍 with Golang 4 - 라우팅

웹 프로그래밍 with Golang 5 - Form & JSON

웹 프로그래밍 with Golang 6 - 데이터베이스

Restful API에 대한 이해가 부족하다면 좋은 REST API 설계 방법에 대한 고찰이라는 포스팅을 한번 보시길 바랍니다.

설계


Tucker의 Go 언어 프로그래밍 교재에 나온 연습문제를 기반으로 Rest API를 설계해보겠습니다.

메서드 URL 동작
GET /news 전체 뉴스 데이터 반환
GET /news/{id} 특정 id의 뉴스 데이터 반환
POST /news 새로운 뉴스 등록
PATCH /news/{id} 특정 id의 뉴스 데이터 수정
DELETE /news/{id} 특정 id의 뉴스 데이터 삭제

구현 이후에는 net/http/httptest 모듈을 이용하여 테스트를 진행하겠습니다.

뉴스 스키마


1
2
3
4
5
6
CREATE TABLE news (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
author TEXT NOT NULL,
content TEXT NOT NULL
)

프로젝트 초기화


1
2
3
4
5
go mod init news-api

go get github.com/gorilla/mux # Router
go get github.com/google/uuid # UUID
go get github.com/mattn/go-sqlite3 # DB Driver

프로젝트 디렉터리를 생성한 다음 go mod init을 이용하여 프로젝트를 초기화 해줍니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
.
├── controller
│ └── news
│ └── news.controller.go
├── go.mod
├── go.sum
├── main.go
├── replit.nix
├── repository
│ ├── connect.go
│ └── news
│ └── news.repository.go
└── service
└── news
└── news.service.go

repositoryservice를 생성해줍니다.

repository는 DB driver를 이용하여 직접 데이터베이스와 통신합니다.

servicerepository를 호출하여 데이터를 획득하고 처리합니다.

controller는 클라이언트가 보낸 데이터를 validation하고 적절한 service를 호출합니다. (라우터)

repository/connect.go


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
package repository

import (
"database/sql"
_ "github.com/mattn/go-sqlite3"
)

func OpenWithMemory() (*sql.DB, error) {
db, err := sql.Open("sqlite3", ":memory:")

if err != nil {
return nil, err
}

err = db.Ping()

if err != nil {
return nil, err
}

_, err = createNewsTable(db)

if err != nil {
return nil, err
}

return db, nil
}

func createNewsTable(db *sql.DB) (sql.Result, error) {
query := `
CREATE TABLE news (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
author TEXT NOT NULL,
content TEXT NOT NULL
)
`

result, err := db.Exec(query)

if err != nil {
return nil, err
}

return result, nil
}

repository/news/news.repository.go


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
package news

import (
"database/sql"
"errors"

"github.com/google/uuid"
_ "github.com/mattn/go-sqlite3"
)

type NewsDto struct {
Title string
Author string
Content string
}

type NewsRaw struct {
Id string
Author string
Title string
Content string
}

type NewsRepository struct {
DB *sql.DB
}

var Repository NewsRepository

func (r *NewsRepository) AssignDB(db *sql.DB) {
r.DB = db
}

func (r *NewsRepository) InsertNews(n NewsDto) (sql.Result, error) {
id, err := uuid.NewRandom()

if err != nil {
return nil, err
}

query := `
INSERT INTO news
(id, title, author, content)
VALUES (?, ?, ?, ?)
`
result, err := r.DB.Exec(query, id.String(), n.Title, n.Author, n.Content)

if err != nil {
return nil, err
}

return result, nil
}

func (r *NewsRepository) GetAllNews() (*[]NewsRaw, error) {
var raws []NewsRaw

query := `SELECT * FROM news`
rows, err := r.DB.Query(query)

for rows.Next() {
var raw NewsRaw
rows.Scan(&raw.Id, &raw.Title, &raw.Author, &raw.Content)
raws = append(raws, raw)
}

if err != nil {
return nil, err
} else {
return &raws, nil
}
}

func (r *NewsRepository) GetOneNews(id string) (*NewsRaw, error) {
var raw NewsRaw

query := `SELECT * FROM news WHERE id = ?`
err := r.DB.QueryRow(query, id).Scan(&raw.Id, &raw.Title, &raw.Author, &raw.Content)

if err != nil {
if err.Error() == "sql: no rows in result set" {
return nil, errors.New("NOT FOUND")
} else {
return nil, err
}
} else {
return &raw, nil
}
}

func (r *NewsRepository) DeleteOneNews(id string) (sql.Result, error) {
query := `DELETE FROM news WHERE id = ?`
result, err := r.DB.Exec(query, id)

if err != nil {
return nil, err
}

affected, err := result.RowsAffected()

if err != nil {
return nil, err
}

if affected == 0 {
return nil, errors.New("NOT FOUND")
}

return result, nil
}

func (r *NewsRepository) UpdateOneNews(id string, n NewsDto) (sql.Result, error) {
query := `UPDATE news SET title = IFNULL(?, title), author = IFNULL(?, author), content = IFNULL(?, content) WHERE id = ?`
var title, author, content *string

if n.Title != "" {
title = &n.Title
}

if n.Author != "" {
author = &n.Author
}

if n.Content != "" {
content = &n.Content
}

result, err := r.DB.Exec(query, title, author, content, id)

if err != nil {
return nil, err
}

affected, err := result.RowsAffected()

if err != nil {
return nil, err
}

if affected == 0 {
return nil, errors.New("NOT FOUND")
}

return result, nil
}

service/news/news.service.go


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
package news

import (
"news-api/repository"
"news-api/repository/news"
)

type NewsService struct {
Repository *news.NewsRepository
}

var Service NewsService

func (s *NewsService) InitService() error {
db, err := repository.OpenWithMemory()

if err != nil {
return err
}

s.Repository = &news.Repository
s.Repository.AssignDB(db)

return nil
}

func (s *NewsService) GetAllNews() (*[]news.NewsRaw, error) {
raws, err := s.Repository.GetAllNews()

return raws, err
}

func (s *NewsService) GetOneNews(id string) (*news.NewsRaw, error) {
raw, err := s.Repository.GetOneNews(id)

return raw, err
}

func (s *NewsService) CreateNews(n news.NewsDto) error {
_, err := s.Repository.InsertNews(n)

return err
}

func (s *NewsService) UpdateNews(id string, n news.NewsDto) error {
_, err := s.Repository.UpdateOneNews(id, n)

return err
}

func (s *NewsService) DeleteNews(id string) error {
_, err := s.Repository.DeleteOneNews(id)

return err
}

controller/news/news.controller.go


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
package news

import (
"encoding/json"
"errors"
"net/http"

"news-api/service/news"
"github.com/gorilla/mux"
)

type CommonResponse struct {
Data interface{} `json:"data"`
Status int `json:"status"`
Error interface{} `json:"error"`
}

func Response(w http.ResponseWriter, data interface{}, status int, err error) {
var res CommonResponse

if status == http.StatusOK {
res.Data = data
res.Status = status
} else {
res.Status = status
res.Error = err.Error()
}

w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
json.NewEncoder(w).Encode(res)
}

func NewController(router *mux.Router) error {
err := news.Service.InitService()

if err != nil {
return err
}

// GET 특정 id의 뉴스 데이터 반환
router.HandleFunc("/news/{id}", func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]

raw, err := news.Service.GetOneNews(id)

if err != nil {
switch err.Error() {
case "NOT FOUND":
Response(w, nil, http.StatusNotFound, errors.New("해당 뉴스가 없습니다."))
default:
Response(w, nil, http.StatusInternalServerError, err)
}
return
}

Response(w, raw, http.StatusOK, nil)

}).Methods("GET")

// GET 전체 뉴스 데이터 반환
router.HandleFunc("/news", func(w http.ResponseWriter, r *http.Request) {
raws, err := news.Service.GetAllNews()

if err != nil {
Response(w, nil, http.StatusInternalServerError, err)
return
}

Response(w, raws, http.StatusOK, nil)

}).Methods("GET")

// POST 새로운 뉴스 등록
router.HandleFunc("/news", func(w http.ResponseWriter, r *http.Request) {
var body struct{
Title string
Author string
Content string
}

err := json.NewDecoder(r.Body).Decode(&body)

if err != nil {
Response(w, nil, http.StatusInternalServerError, err)
}

if body.Title == "" || body.Author == "" || body.Content == "" {
Response(w, nil, http.StatusBadRequest, errors.New("파라미터가 누락되었습니다."))
return
}

err = news.Service.CreateNews(body)

if err != nil {
Response(w, nil, http.StatusInternalServerError, err)
return
}

Response(w, "OK", http.StatusOK, nil)

}).Methods("POST")

// PATCH 특정 id의 뉴스 데이터 수정
router.HandleFunc("/news/{id}", func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]

var body struct{
Title string
Author string
Content string
}

err := json.NewDecoder(r.Body).Decode(&body)

if err != nil {
Response(w, nil, http.StatusInternalServerError, err)
}

err = news.Service.UpdateNews(id, body)

if err != nil {
switch err.Error() {
case "NOT FOUND":
Response(w, nil, http.StatusNotFound, errors.New("해당 뉴스가 없습니다."))
default:
Response(w, nil, http.StatusInternalServerError, err)
}
return
}

Response(w, "OK", http.StatusOK, nil)

}).Methods("PATCH")

// DELETE 특정 id의 뉴스 데이터 삭제
router.HandleFunc("/news/{id}", func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]

err = news.Service.DeleteNews(id)

if err != nil {
switch err.Error() {
case "NOT FOUND":
Response(w, nil, http.StatusNotFound, errors.New("해당 뉴스가 없습니다."))
default:
Response(w, nil, http.StatusInternalServerError, err)
}
return
}

Response(w, "OK", http.StatusOK, nil)

}).Methods("DELETE")

return nil
}

main.go


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

import (
"net/http"

"github.com/gorilla/mux"
"news-api/controller/news"
)

func main() {
r := mux.NewRouter()
err := news.NewController(r)

if err != nil {
panic("서버 실행에 실패했습니다.")
}

http.ListenAndServe(":3000", r)
}

설명


News API는 공통 응답 포맷(CommonResponse)을 갖도록 설계하였습니다.

1
2
3
4
5
type CommonResponse struct {
Data interface{} `json:"data"`
Status int `json:"status"`
Error interface{} `json:"error"`
}

status200일 경우 data가 존재하고 errornull입니다.

status200이 아닐 경우 datanull이고 error가 존재합니다.

테스트 코드 작성 (news_controller_test.go)


테스트를 돕는 패키지를 설치합니다.

1
go get github.com/strechr/testify

루트 위치에 테스트 파일(news_controller_test.go)을 만듭니다.

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
package main

import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/gorilla/mux"
"news-api/controller/news"
"strings"
)

func TestCreateAndSearch(t *testing.T) {
var res *httptest.ResponseRecorder
var req *http.Request
var assert = assert.New(t)
var r = mux.NewRouter()

news.NewController(r)

// 생성
res = httptest.NewRecorder()
req = httptest.NewRequest("POST", "/news", strings.NewReader(`{"title": "Hello", "author": "JehwanYoo", "Content": "This is Test"}`))

r.ServeHTTP(res, req)
assert.Equal(http.StatusOK, res.Code)

// 조회
res = httptest.NewRecorder()
req = httptest.NewRequest("GET", "/news", nil)
r.ServeHTTP(res, req)

var response struct {
Data []struct {
Id string
Title string
Author string
Content string
}
Status int
Error interface{}
}

err := json.NewDecoder(res.Body).Decode(&response)

assert.Nil(err)
assert.Equal(http.StatusOK, res.Code)
assert.Nil(response.Error)
assert.Equal(http.StatusOK, response.Status)
assert.Equal("Hello", response.Data[0].Title)
assert.Equal("JehwanYoo", response.Data[0].Author)
assert.Equal("This is Test", response.Data[0].Content)
}

테스트는 위와 같이 설계해주면 됩니다. 나머지 API에 대한 테스트도 구현해보세요.