Notice
Recent Posts
Recent Comments
«   2025/02   »
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
Archives
Today
Total
관리 메뉴

SYDev

[Go 언어로 배우는 웹 애플리케이션 개발] Chapter 17. 엔드포인트 추가하기 본문

GDGoC KHU 1th

[Go 언어로 배우는 웹 애플리케이션 개발] Chapter 17. 엔드포인트 추가하기

시데브 2024. 11. 13. 21:22

Section 65. Entity.Task 타입 정의와 영구 저장 방법의 임시 구현

1. Entity.Task 타입 정의

  • 자체 타입을 지정하여 잘못된 대입 방지
package entity

import "time"

// 자체 타입을 지정하여, 잘못된 대입 방지
type TaskID int64
type TaskStatus string

const (
	TaskStatusTodo TaskStatus = "todo"
	TaskStatusDoing TaskStatus = "doing"
	TaskStatusDone TaskStatus = "done"
)

type Task struct {
	ID		TaskID		`json:"id"`
	Title	string		`json:"title"`
	Status	TaskStatus	`json:"status"`
	Created	time.Time	`json:"created"`
}

type Tasks []*Task

 

 

-> 3번째 줄은 타입 추론(type inference)에 의해서 컴파일러가 TaskID 타입의 1로 해석 

 

2. entity.Task의 영구 저장 방법 임시 구현

  • entity.Task 타입값을 영구 저장하는 임시 구현
  • store directory에 store.go라는 파일로 저장
package stroe

import (
	"errors"

	"github.com/Imsyp/go_todo_app/chapter17/section65/entity"
)

var (
	Tasks = &TaskStore{Tasks: map[entity.TaskID]*entity.Task{}}
	ErrNotFound = errors.New("not found")
)

type TaskStore struct {
	// 동작 확인용
	LastID entity.TaskID
	Tasks map[entity.TaskID]*entity.Task
}

func (ts *TaskStore) Add(t *entity.Task) (entity.TaskID, error) {
	ts.LastID++
	t.ID = ts.LastID
	ts.Tasks[t.ID] = t
	return t.ID, nil
}

func (ts *TaskStore) Get(id entity.TaskID) (*entity.Task, error) {
	if ts, ok := ts.Tasks[id]; ok {
		return ts, nil
	}
	return nil, ErrNotFound
}

// 정렬이 끝난 task 목록을 반환
func (ts *TaskStore) All() entity.Tasks {
	tasks := make([]*entity.Task, len(ts.Tasks))
	for i, t := range ts.Tasks {
		tasks[i-1] = t
	}
	return tasks
}

 

-> Task.ID field: 실제 구현에서는 RDBMS에 의해 자동 증가한 값을 설정해야 하므로, TaskStore 타입에서도 내부에 할당이 끝난 ID 번호를 저장

 

Section 66. 헬퍼 함수 구현

  • 지금부터 작성하는 몇 개의 HTTP handler -> response data를 JSON으로 변환해서 상태 코드와 함게 http.ResponseWriter 인터페이스를 충족하는 type의 값으로 기록하는 작업을 반복
  • HTTP handler 구현을 공통화하기 위해 미리 helper function 작성
package handler

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

type ErrResponse struct {
	Message string		`json:"message"`
	Details []string	`json:"details,omitempty"`
}

func RespondJSON(ctx context.Context, w http.ResponseWriter, body any, status int) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	bodyBytes, err := json.Marshal(body)
	if err != nil {
		fmt.Printf("encode response error: %v", err)
		w.WriterHeader(http.StatusInternalServerError)
		rsp := ErrResponse{
			Message: http.StatusText(http.StatusInternalServerError),
			if err := json.NewEncoder(w).Encode(rsp); err != nil {
				fmt.Printf("write error response error: %v", err)
			}
			return
		}
	}

	w.WriteHeader(status)
	if _, err := fmt.Fprintf(w, "%s", bodyBytes); err != nil {
		fmt.Printf("write response error: %v", err)
	}
}

-> HTTP handler 내에서 귀찮은 JSON 응답 작성을 간략화

 

1. 테스트용 헬퍼 함수 구현

  • HTTP 핸들러의 테스트에서 사용할 검증 순서도 공통 헬퍼로 작성
  • 복잡한 JSON 구조를 문자열로 비교하면 어디에 차이가 있는지 파악하기 어려움
    • AssetJSON: JSON 문자열을 Unmarshal하고 cmp 패키지를 사용해 차이를 비교
package testutil

import (
	"encoding/json"
	"io"
	"net/http"
	"os"
	"testing"
	"github.com/google/go-cmp/cmp"
)

func AssertJson(t *testing.T, want, got []byte) {
	t.Helper()

	var jw, jg any
	if err := json.Unmarshal(want, &jw); err != nil {
		t.Fatalf("cannot unmarshal want %q: %v", want, err)
	}
	if err := json.Unmarshal(got, &jg); err != nil {
		t.Fatalf("cannot unmarshal got %q: %v", got, err)
	}
	if diff := cmp.Diff(jg, jw); diff != "" {
		t.Errorf("got differs: (-got +want)\n%s", diff)
	}
}

func AssertResponse(t *testing.T, got *http.Response, status int, body []byte) {
	t.Helper()
	t.Cleanup(func() { _ = got.Body.Close() })
	gb, err := io.ReadAll(got.Body)
	if err != nil {
		t.Fatal(err)
	}
	if got.StatusCode != status {
		t.Fatalf("want status %d, but got %d, body: %q", status, got.StatusCode, gb)
	}

	if len(gb) == 0 && len(body) ==0 {
		// 어느 쪽이든 응답 바디가 없으므로 AssetJSON 호출할 필요 X
		return
	}
	AssetJSON(t, body, gb)
}

// 입력값, 기댓값을 파일에서 읽어옴
func LoadFile(t *testing.T, path string) []byte {
	t.Helper()

	bt, err := os.ReadFile(path)
	if err != nil {
		t.Fatalf("cannot read from %q: %v", path, err)
	}
	return bt
}

 

marshal, unmarshal?
- marshal: json 형태의 구조체 -> byte나 string 형태의 data로 encoding
- unmarshal: byte나 string 형태의 data -> json 형태의 구조체로 decoding

 

Section 67. Task를 등록하는 Endpoint 구현

  • ToDo application에서 task를 등록하는 HTTP handler 구현
    • POST /task로 오는 요청 처리
    • task title을 포함한 JSON request body를 반드시 지정해야 함
    • task가 성공적으로 등록되면 해당 task의 ID를 response body로 반환
  • AddTask type: http.HandlerFunc type을 충족하는 ServeHTTP method를 구현
  • request 처리가 정상 완료된 경우 RespondJSON을 사용해 JSON 응답을 반환
  • 오류가 발생한 경우 ErrResponse type에 정보를 포함시켜 RespondJSON을 사용해 JSON 응답을 반환
package handler

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

	"github.com/Imsyp/go_todo_app/chapter17/section65/entity"
	"github.com/Imsyp/go_todo_app/chapter17/section65/store"
	"github.com/go-playground/validator/v10"
)

type AddTask struct {
	Store		*store.TaskStore
	validator	*validator.Validate
}

func (at *AddTask) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	var b struct {
		Title string `json:"title" validate:"required"`
	}
	if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
		RespondJSON(ctx, w, &ErrResponse{
			Message: err.Error(),
		}, http.StatusInternalServerError)
		return
	}
	if err := at.Validator.Struct(b); err != nil {
		RespondJSON(ctx, w, &ErrResponse{
			Message: err.Error(),
		}, http.StatusBadRequest)
		return
	}

	t := &entity.Task{
		Title:		b.Title,
		Status: 	"todo",
		Created:	time.Now(),
	}
	id, err := store,Tasks.Add(t)
	if err != nil {
		RespondJSON(ctx, w, &ErrResponse{
			Message: err.Error(),
		}, http.StatusInternalServerError)
		return
	}
	rsp := struct {
		ID int `json:"id"`
	}{ID: int(id)}
	RespondJSON(ctx, w, rsp, http.StatusOK)
}

 

요청 바디 검증

  • 해당 HTTP handler에서는 task title을 포함한 JSON request body를 필수로 요구
  • request body를 검증하기 위해 request body를 Unmarshal한 type의 값에 대해 if문을 반복해서 검증하는 방법도 존재 
  • 하지만, JSON 구조가 방대하거나 JSON의 각 필드 조건이 복잡한 경우 JSON 로직만으로도 많은 로직 작성해야함, 경우에 따라 로직을 작성하지 않아 놓치는 필드가 발생
  • github.com/go-playground/validator: Unmarshal한 type에 validate라는 key를 태그로 사용해 해당 필드의 검증 조건을 설정할 수 있음
  • 설정한 조건은 *validator.ValidateStruct method로 검증할 수 있음
  • 정의 완료된 조건도 다수 있으며, 'ip 주소로 유효한가', '전화번호로 유효한가' 등의 검증도 간단히 정의 가능
  • 위 코드에서는 task title이 필수적 -> Title string 'json: "title" validate: "required"'라고 설정

 

Section 68. 테이블 주도 테스트와 골든 테스트를 조합한 테스트 코드

  • TestAddTask 함수는 httptest 패키지를 사용해 HTTP handler에 request를 보내서 테스트 수행
  • tests 변수로 여러 개의 테스트 데이터 선언 -> t.Run method로 서브 테스트 실행
  • 테이블 주도 테스트(Table Driven  Test): 여러 개의 입력 및 기댓값을 조합해서 공통화된 실행 순서로 테스트하는 패턴을 Go에서는 테이블 주도 테스트라 한다.
  • 골든 테스트(Golden Test): 테스트 입력값이나 기댓값을 파일에 저장한 테스트 -> 테스트 코드와 별도로 저장하는 데이터를 *.json.golden 형태로 저장하기 때문에 golden test라 부름
package handler

import (
	"bytes"
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/Imsyp/go_todo_app/chapter17/section65/entity"
	"github.com/Imsyp/go_todo_app/chapter17/section65/store"
	"github.com/Imsyp/go_todo_app/chapter17/section65/testutil"
	"github.com/go-playground/validator/v10"
)

func TestAddTask(t *testing.T) {
	type want struct {
		status int
		rspFile string
	}
	tests := map[string]struct {
		reqFile string
		want	want
	}{
		"ok": {
			reqFile: "testdata/add_task/ok_req.json.golden",
			want: want{
				status: http.StatusOK,
				rspFile: "testdata/add_task/ok_rsp.json.golden",
			},
		},
		"badRequest": {
			reqFile: "testdata/add_task/bad_req.json.golden",
			want: want{
				status: http.StatusBadRequest,
				rspFile: "testdata/add_task/bad_rsp.json.golden"
			},
		},
	}
	for n, tt := range test {
		tt := tt
		t.Run(n, func(t *testing.T) {
			t.Parallel()

			w := httptest.NewRecorder()
			r := httptest.NewRequest(
				http.MethodPost,
				"/tasks",
				bytes.NewReader(testutil,LoadFile(t, tt.reqFile)),
			)

			sut := AddTask{Store: &store.TaskStore{
				Tasks: map[entity.TaskID]*entity.Task{},
			}, Validator: validator.New()}
			sut.ServeHTTP(w, r)

			resp := w.Result()
			testutil.AssertResponse(t,
				resp, tt.want.status, testutil.LoadFile(t, tt.want.rspFile),	
			)
		})
	}
	
}

-> IDE에서 *.json.golden 확장자를 JSON 파일로 인식하도록 설정해야 함

 

Section 70. HTTP Handler를 Routing으로 설정

  • 위에서 구현한 AddTask type, ListTask type의 HTTP handler를 HTTP 서버의 엔드 포인트로 설정
  • problem -> 표준 패키지인 http.ServerMux type의 routing 설정이 쉽지 않음 -> 아래 구현이 어려움
    • /users/10처럼 url에 포함된 경로 파라미터의 해석
    • GET /users와 POST /users같이 HTTP method의 차이에 의한 다른 HTTP handler 구현
  • 이런 이유로 open source를 이용하여 routing 구현

1. Github.com/go-chi/chi를 사용한 유연한 routing 설정

  • github.com/go-chi/chi: go 언어에서 라우팅 기능을 제공하는 오픈소스
  • chi.NewRoudter 함수의 *chi.Mux 타입값은 http.Handler 인터페이스를 충족 -> NewMux 함수의 시그니처를 변경하지 않고 내부 구현을 변경
package main

import (
	"net/http"
	
	"github.com/Imsyp/go_todo_app/chapter17/section70/entity"
	"github.com/Imsyp/go_todo_app/chapter17/section70/store"
	"github.com/Imsyp/go_todo_app/chapter17/section70/testutil"

	"github.com/go-chi/chi/v5"
	"github.com/go-playground/validator/v10"
)

func NewMux() http.Handler {
	mux := http.NewRouter()
	mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json; charset=utf-8")
		_, _ = w.Write([]bute(`{"status": "ok"}`))
	})
	v := validator.New()
	mux.Handle("/tasks", &handler.AddTask{Store: store:Tasks, Validator: v})
	at := &handler.AddTask{Store: store.Tasks, Validator: v}
	mux.Post("/tasks", at.ServeHTTP)
	lt := &handler.ListTask{Store: store.Tasks}
	mux.Get("/tasks", lt.serveHTTP)
	return mux
}

 

Section 71. 동작 검증


참고자료