diff --git a/.gitignore b/.gitignore index 09944b57..3ea2c01d 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ go.work # Students students.txt +.env diff --git a/exercise1/problem1/main.go b/exercise1/problem1/main.go index dfca465c..bb6e14e0 100644 --- a/exercise1/problem1/main.go +++ b/exercise1/problem1/main.go @@ -1,3 +1,9 @@ package main -func addUp() {} +func addUp(num int) int { + sum := 0 + for i := 1; i <= num; i++ { + sum = sum + i + } + return sum +} diff --git a/exercise1/problem10/main.go b/exercise1/problem10/main.go index 04ec3430..6cdd428e 100644 --- a/exercise1/problem10/main.go +++ b/exercise1/problem10/main.go @@ -1,3 +1,15 @@ package main -func sum() {} +import ( + "errors" + "strconv" +) + +func sum(a string, b string) (string, error) { + sA, errA := strconv.Atoi(a) + sB, errB := strconv.Atoi(b) + if errA != nil || errB != nil { + return "", errors.Join(errA, errB) + } + return strconv.Itoa(sA + sB), nil +} diff --git a/exercise1/problem2/main.go b/exercise1/problem2/main.go index 2ca540b8..98d166db 100644 --- a/exercise1/problem2/main.go +++ b/exercise1/problem2/main.go @@ -1,3 +1,7 @@ package main -func binary() {} +import "fmt" + +func binary(num int) string { + return fmt.Sprintf("%b", num) +} diff --git a/exercise1/problem3/main.go b/exercise1/problem3/main.go index d346641a..c959ef96 100644 --- a/exercise1/problem3/main.go +++ b/exercise1/problem3/main.go @@ -1,3 +1,9 @@ package main -func numberSquares() {} +func numberSquares(num int) int { + var result int + for i := 1; i <= num; i++ { + result = result + (i * i) + } + return result +} diff --git a/exercise1/problem4/main.go b/exercise1/problem4/main.go index 74af9044..602dba07 100644 --- a/exercise1/problem4/main.go +++ b/exercise1/problem4/main.go @@ -1,3 +1,15 @@ package main -func detectWord() {} +import ( + "unicode" +) + +func detectWord(crowd string) string { + var result string + for _, s := range crowd { + if !unicode.IsUpper(s) { + result = result + string(s) + } + } + return result +} diff --git a/exercise1/problem5/main.go b/exercise1/problem5/main.go index c5a804c9..db2b8c81 100644 --- a/exercise1/problem5/main.go +++ b/exercise1/problem5/main.go @@ -1,3 +1,12 @@ package main -func potatoes() {} +func potatoes(crowd string) int { + var result int + strL := len("potato") + for i := 0; i <= len(crowd)-strL; i++ { + if crowd[i:i+strL] == "potato" { + result++ + } + } + return result +} diff --git a/exercise1/problem6/main.go b/exercise1/problem6/main.go index 06043890..da650cd2 100644 --- a/exercise1/problem6/main.go +++ b/exercise1/problem6/main.go @@ -1,3 +1,19 @@ package main -func emojify() {} +import "strings" + +var emoji = map[string]string{ + "smile": "🙂", + "grin": "😀", + "sad": "😥", + "mad": "😠", +} + +func emojify(text string) string { + for i, v := range emoji { + if strings.Contains(text, i) { + text = strings.Replace(text, i, v, 1) + } + } + return text +} diff --git a/exercise1/problem7/main.go b/exercise1/problem7/main.go index 57c99b5c..02fc8aa9 100644 --- a/exercise1/problem7/main.go +++ b/exercise1/problem7/main.go @@ -1,3 +1,21 @@ package main -func highestDigit() {} +import "slices" + +func highestDigit(num int) int { + if num != 0 { + numS := ItoSlice(num) + return slices.Max(numS) + } + return 0 +} + +func ItoSlice(n int) []int { + var numS []int + for n >= 1 { + i := n % 10 + numS = append(numS, i) + n = n / 10 + } + return numS +} diff --git a/exercise1/problem8/main.go b/exercise1/problem8/main.go index 97fa0dae..2d43c13a 100644 --- a/exercise1/problem8/main.go +++ b/exercise1/problem8/main.go @@ -1,3 +1,12 @@ package main -func countVowels() {} +func countVowels(text string) int { + var count int + for _, v := range text { + switch v { + case 'a', 'e', 'i', 'o', 'u', 'A', 'E', 'I', 'O', 'U': + count++ + } + } + return count +} diff --git a/exercise1/problem9/main.go b/exercise1/problem9/main.go index e8c84a54..d6870336 100644 --- a/exercise1/problem9/main.go +++ b/exercise1/problem9/main.go @@ -1,7 +1,13 @@ package main -func bitwiseAND() {} +func bitwiseAND(a int, b int) int { + return a & b +} -func bitwiseOR() {} +func bitwiseOR(a int, b int) int { + return a | b +} -func bitwiseXOR() {} +func bitwiseXOR(a int, b int) int { + return a ^ b +} diff --git a/exercise2/problem1/problem1.go b/exercise2/problem1/problem1.go index 4763006c..c3fd2632 100644 --- a/exercise2/problem1/problem1.go +++ b/exercise2/problem1/problem1.go @@ -1,4 +1,11 @@ package problem1 -func isChangeEnough() { +var cents = []float32{0.25, 0.10, 0.05, 0.01} + +func isChangeEnough(changes [4]int, total float32) bool { + var sum float32 + for key, index := range changes { + sum += cents[key] * float32(index) + } + return sum >= total } diff --git a/exercise2/problem10/problem10.go b/exercise2/problem10/problem10.go index 7142a022..3d42052b 100644 --- a/exercise2/problem10/problem10.go +++ b/exercise2/problem10/problem10.go @@ -1,3 +1,11 @@ package problem10 -func factory() {} +func factory() (map[string]int, func(brand string) func(int)) { + result := make(map[string]int) + return result, func(brand string) func(int) { + result[brand] = 0 + return func(i int) { + result[brand] = result[brand] + i + } + } +} diff --git a/exercise2/problem11/problem11.go b/exercise2/problem11/problem11.go index 33988711..b9812a9a 100644 --- a/exercise2/problem11/problem11.go +++ b/exercise2/problem11/problem11.go @@ -1,3 +1,13 @@ package problem11 -func removeDups() {} +import "slices" + +func removeDups[T comparable](nums []T) []T { + result := make([]T, 0) + for _, v := range nums { + if !slices.Contains(result, v) { + result = append(result, v) + } + } + return result +} diff --git a/exercise2/problem12/problem12.go b/exercise2/problem12/problem12.go index 4c1ae327..15a5e185 100644 --- a/exercise2/problem12/problem12.go +++ b/exercise2/problem12/problem12.go @@ -1,3 +1,32 @@ package problem11 -func keysAndValues() {} +import ( + "fmt" + "sort" +) + +func keysAndValues[K comparable, V any](m map[K]V) ([]K, []V) { + if m == nil { + return []K{}, []V{} + } + + keys := make([]K, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + + sort.Slice(keys, func(i, j int) bool { + return stringify(keys[i]) < stringify(keys[j]) + }) + + values := make([]V, len(keys)) + for i, k := range keys { + values[i] = m[k] + } + + return keys, values +} + +func stringify[T any](v T) string { + return fmt.Sprintf("%v", v) +} diff --git a/exercise2/problem2/problem2.go b/exercise2/problem2/problem2.go index fdb199f0..2db8363f 100644 --- a/exercise2/problem2/problem2.go +++ b/exercise2/problem2/problem2.go @@ -1,4 +1,15 @@ package problem2 -func capitalize() { +import "strings" + +func capitalize(names []string) []string { + for i, v := range names { + if v == "" { + continue + } + firstLetter := strings.ToUpper(string(v[0])) + lowerS := strings.ToLower(v[1:]) + names[i] = firstLetter + lowerS + } + return names } diff --git a/exercise2/problem3/problem3.go b/exercise2/problem3/problem3.go index f183fafb..871cd318 100644 --- a/exercise2/problem3/problem3.go +++ b/exercise2/problem3/problem3.go @@ -9,5 +9,43 @@ const ( lr dir = "lr" ) -func diagonalize() { +func diagonalize(n int, d dir) [][]int { + matrix := make([][]int, n) + switch d { + case ul: + count := 0 + getMatrix(count, matrix, n, true, true) + break + case ll: + count := n - 1 + getMatrix(count, matrix, n, true, false) + break + case ur: + count := n - 1 + getMatrix(count, matrix, n, false, true) + break + case lr: + count := (n - 1) * 2 + getMatrix(count, matrix, n, false, false) + break + } + return matrix +} + +func getMatrix(count int, matrix [][]int, n int, o bool, countChecker bool) { + for i := range n { + matrix[i] = make([]int, n) + for j := range n { + if o { + matrix[i][j] = count + j + } else { + matrix[i][j] = count - j + } + } + if countChecker { + count++ + } else { + count-- + } + } } diff --git a/exercise2/problem4/problem4.go b/exercise2/problem4/problem4.go index 1f680a4d..c31d499c 100644 --- a/exercise2/problem4/problem4.go +++ b/exercise2/problem4/problem4.go @@ -1,4 +1,11 @@ package problem4 -func mapping() { +import "strings" + +func mapping(inp []string) map[string]string { + intM := make(map[string]string) + for _, v := range inp { + intM[v] = strings.ToUpper(v) + } + return intM } diff --git a/exercise2/problem5/problem5.go b/exercise2/problem5/problem5.go index 43fb96a4..c16c6ad6 100644 --- a/exercise2/problem5/problem5.go +++ b/exercise2/problem5/problem5.go @@ -1,4 +1,45 @@ package problem5 -func products() { +import "sort" + +type Catalog struct { + Key string + Value int +} + +type CatalogList []Catalog + +func (c CatalogList) Len() int { + return len(c) +} + +func (c CatalogList) Less(i, j int) bool { + if c[i].Value == c[j].Value { + return c[i].Key < c[j].Key + } + return c[i].Value > c[j].Value +} + +func (c CatalogList) Swap(i, j int) { + c[i], c[j] = c[j], c[i] +} + +func products(catalog map[string]int, minPrice int) []string { + prices := make(CatalogList, len(catalog)) + i := 0 + + for k, v := range catalog { + if v > minPrice { + prices[i] = Catalog{k, v} + i++ + } + } + sort.Sort(prices) + result := make([]string, 0) + for _, v := range prices { + if v.Key != "" { + result = append(result, v.Key) + } + } + return result } diff --git a/exercise2/problem6/problem6.go b/exercise2/problem6/problem6.go index 89fc5bfe..384774ae 100644 --- a/exercise2/problem6/problem6.go +++ b/exercise2/problem6/problem6.go @@ -1,4 +1,12 @@ package problem6 -func sumOfTwo() { +func sumOfTwo(first []int, second []int, sum int) bool { + for _, fv := range first { + for _, sv := range second { + if fv+sv == sum { + return true + } + } + } + return false } diff --git a/exercise2/problem7/problem7.go b/exercise2/problem7/problem7.go index 32514209..b6cdf173 100644 --- a/exercise2/problem7/problem7.go +++ b/exercise2/problem7/problem7.go @@ -1,4 +1,5 @@ package problem7 -func swap() { +func swap(a *int, b *int) { + *a, *b = *b, *a } diff --git a/exercise2/problem8/problem8.go b/exercise2/problem8/problem8.go index 9389d3b0..b4086870 100644 --- a/exercise2/problem8/problem8.go +++ b/exercise2/problem8/problem8.go @@ -4,13 +4,12 @@ func simplify(list []string) map[string]int { var indMap map[string]int indMap = make(map[string]int) - load(&indMap, &list) - + load(indMap, list) return indMap } -func load(m *map[string]int, students *[]string) { - for i, name := range *students { - (*m)[name] = i +func load(m map[string]int, students []string) { + for i, name := range students { + m[name] = i } } diff --git a/exercise2/problem9/problem9.go b/exercise2/problem9/problem9.go index fc96d21a..372cd80e 100644 --- a/exercise2/problem9/problem9.go +++ b/exercise2/problem9/problem9.go @@ -1,3 +1,11 @@ package problem9 -func factory() {} +func factory(multiple int) func(nums ...int) []int { + return func(nums ...int) []int { + result := make([]int, 0) + for _, v := range nums { + result = append(result, v*multiple) + } + return result + } +} diff --git a/exercise3/problem1/problem1.go b/exercise3/problem1/problem1.go index d45605c6..2dc2fa74 100644 --- a/exercise3/problem1/problem1.go +++ b/exercise3/problem1/problem1.go @@ -1,3 +1,38 @@ package problem1 -type Queue struct{} +import "errors" + +type Queue struct { + vals []any +} + +func (q *Queue) Enqueue(val any) { + q.vals = append(q.vals, val) +} + +func (q *Queue) Dequeue() (any, error) { + if len(q.vals) == 0 { + return nil, errors.New("queue is empty") + } + first := q.vals[0] + q.vals = append(q.vals[:0], q.vals[1:]...) + return first, nil +} + +func (q *Queue) Peek() (any, error) { + if len(q.vals) == 0 { + return nil, errors.New("queue empty") + } + return q.vals[0], nil +} + +func (q *Queue) Size() int { + return len(q.vals) +} + +func (q *Queue) IsEmpty() bool { + if len(q.vals) > 0 { + return false + } + return true +} diff --git a/exercise3/problem2/problem2.go b/exercise3/problem2/problem2.go index e9059889..c4a88265 100644 --- a/exercise3/problem2/problem2.go +++ b/exercise3/problem2/problem2.go @@ -1,3 +1,35 @@ package problem2 -type Stack struct{} +import "errors" + +type Stack struct { + vals []any +} + +func (s *Stack) Push(val any) { + s.vals = append(s.vals, val) +} + +func (s *Stack) Pop() (any, error) { + if len(s.vals) == 0 { + return nil, errors.New("stack is empty") + } + topElement := s.vals[len(s.vals)-1] + s.vals = s.vals[:len(s.vals)-1] + return topElement, nil +} + +func (s *Stack) Peek() (any, error) { + if len(s.vals) == 0 { + return nil, errors.New("stack is empty") + } + return s.vals[len(s.vals)-1], nil +} + +func (s *Stack) Size() int { + return len(s.vals) +} + +func (s *Stack) IsEmpty() bool { + return len(s.vals) == 0 +} diff --git a/exercise3/problem3/problem3.go b/exercise3/problem3/problem3.go index d8d79ac0..7ebf4124 100644 --- a/exercise3/problem3/problem3.go +++ b/exercise3/problem3/problem3.go @@ -1,3 +1,106 @@ package problem3 -type Set struct{} +import ( + "slices" +) + +type Set struct { + vals []any + size int +} + +func NewSet() *Set { + return &Set{[]any{}, 0} +} + +func (s *Set) Add(val any) { + if !s.Has(val) { + s.vals = append(s.vals, val) + s.size += 1 + } +} + +func (s *Set) Remove(val any) { + key := slices.Index(s.vals, val) + if key >= 0 { + s.vals = slices.Delete(s.vals, key, key+1) + s.size -= 1 + } +} + +func (s *Set) IsEmpty() bool { + return s.size == 0 +} + +func (s *Set) Size() int { + return s.size +} + +func (s *Set) List() []any { + return s.vals +} + +func (s *Set) Has(val any) bool { + if slices.Contains(s.vals, val) { + return true + } + return false +} + +func (s Set) Copy() *Set { + newVals := make([]any, len(s.vals)) + copy(newVals, s.vals) + return &Set{vals: newVals, size: s.size} +} + +func (s *Set) Difference(data *Set) Set { + result := s.Copy() + for _, v := range s.vals { + if data.Has(v) { + result.Remove(v) + } + } + return *result +} + +func (s *Set) IsSubset(data *Set) bool { + for _, v := range s.vals { + if !data.Has(v) { + return false + } + } + return true +} + +func Union(data ...*Set) Set { + slice := NewSet() + for _, s := range data { + for _, v := range s.vals { + if !slice.Has(v) { + slice.Add(v) + } + } + } + return *slice +} + +func Intersect(data ...*Set) Set { + if len(data) == 0 { + return Set{} + } + + slice := NewSet() + for _, v := range data[0].vals { + foundInAll := true + for _, s := range data[1:] { + if !s.Has(v) { + foundInAll = false + break + } + } + if foundInAll { + slice.Add(v) + } + } + return *slice +} diff --git a/exercise3/problem4/problem4.go b/exercise3/problem4/problem4.go index ebf78147..f8c9c113 100644 --- a/exercise3/problem4/problem4.go +++ b/exercise3/problem4/problem4.go @@ -1,3 +1,96 @@ package problem4 -type LinkedList struct{} +import "fmt" + +type Element[T comparable] struct { + value T + next *Element[T] +} + +type LinkedList[T comparable] struct { + head *Element[T] + size int +} + +func (ll *LinkedList[T]) Add(element *Element[T]) { + if ll.head == nil { + ll.head = element + } else { + current := ll.head + for current.next != nil { + current = current.next + } + current.next = element + } + ll.size++ +} + +func (ll *LinkedList[T]) Insert(element *Element[T], position int) error { + if position < 0 || position > ll.size { + return fmt.Errorf("invalid position") + } + if position == 0 { + element.next = ll.head + ll.head = element + ll.size++ + return nil + } + current := ll.head + for i := 0; i < position-1; i++ { + current = current.next + } + element.next = current.next + current.next = element + ll.size++ + return nil +} + +func (ll *LinkedList[T]) Delete(element *Element[T]) error { + if ll.head == nil { + return fmt.Errorf("list is empty") + } + if ll.head.value == element.value { + ll.head = ll.head.next + ll.size-- + return nil + } + current := ll.head + for current.next != nil { + if current.next.value == element.value { + current.next = current.next.next + ll.size-- + return nil + } + current = current.next + } + return fmt.Errorf("element not found") +} + +func (ll *LinkedList[T]) Find(value T) (*Element[T], error) { + current := ll.head + for current != nil { + if current.value == value { + return current, nil + } + current = current.next + } + return nil, fmt.Errorf("element not found") +} + +func (ll *LinkedList[T]) List() []T { + result := make([]T, 0, ll.size) + current := ll.head + for current != nil { + result = append(result, current.value) + current = current.next + } + return result +} + +func (ll *LinkedList[T]) Size() int { + return ll.size +} + +func (ll *LinkedList[T]) IsEmpty() bool { + return ll.size == 0 +} diff --git a/exercise3/problem5/problem5.go b/exercise3/problem5/problem5.go index 4177599f..d03c0324 100644 --- a/exercise3/problem5/problem5.go +++ b/exercise3/problem5/problem5.go @@ -1,3 +1,15 @@ package problem5 -type Person struct{} +type Person struct { + name string + age int +} + +func (m *Person) compareAge(person *Person) string { + if m.age < person.age { + return person.name + " is older than me." + } else if m.age > person.age { + return person.name + " is younger than me." + } + return person.name + " is the same age as me." +} diff --git a/exercise3/problem6/problem6.go b/exercise3/problem6/problem6.go index 4e8d1af8..32fad2a3 100644 --- a/exercise3/problem6/problem6.go +++ b/exercise3/problem6/problem6.go @@ -1,7 +1,31 @@ package problem6 -type Animal struct{} +type Len interface { + Lens() int +} -type Insect struct{} +type Animal struct { + name string + legsNum int +} -func sumOfAllLegsNum() {} +func (a *Animal) Lens() int { + return a.legsNum +} + +type Insect struct { + name string + legsNum int +} + +func (i *Insect) Lens() int { + return i.legsNum +} + +func sumOfAllLegsNum(lens ...Len) int { + sum := 0 + for _, v := range lens { + sum = sum + v.Lens() + } + return sum +} diff --git a/exercise3/problem7/problem7.go b/exercise3/problem7/problem7.go index 26887151..d4ea836d 100644 --- a/exercise3/problem7/problem7.go +++ b/exercise3/problem7/problem7.go @@ -1,10 +1,55 @@ package problem7 +import "fmt" + +type Drawable interface { + transaction(sum int) +} + +type Send interface { + sendPackage(name string) +} + type BankAccount struct { + name string + balance int +} + +func (b *BankAccount) transaction(sum int) { + b.balance = b.balance - sum } type FedexAccount struct { + name string + packages []string +} + +func (s *FedexAccount) sendPackage(name string) { + s.packages = append(s.packages, fmt.Sprintf("%s send package to %s", s.name, name)) } type KazPostAccount struct { + name string + balance int + packages []string +} + +func (b *KazPostAccount) transaction(sum int) { + b.balance = b.balance - sum +} + +func (s *KazPostAccount) sendPackage(name string) { + s.packages = append(s.packages, fmt.Sprintf("%s send package to %s", s.name, name)) +} + +func withdrawMoney(sum int, accounts ...Drawable) { + for _, v := range accounts { + v.transaction(sum) + } +} + +func sendPackagesTo(name string, accounts ...Send) { + for _, v := range accounts { + v.sendPackage(name) + } } diff --git a/exercise4/bot/bot.zip b/exercise4/bot/bot.zip new file mode 100644 index 00000000..de3f9227 Binary files /dev/null and b/exercise4/bot/bot.zip differ diff --git a/exercise4/bot/go.sum b/exercise4/bot/go.sum new file mode 100644 index 00000000..a105e617 --- /dev/null +++ b/exercise4/bot/go.sum @@ -0,0 +1,2 @@ +github.com/talgat-ruby/exercises-go/exercise4/judge v0.0.0-20241019003312-fda19e97708b h1:AAi4/EE39U7ZOe9wGHNxEX3s/0agWdnJvvko6FJHuSw= +github.com/talgat-ruby/exercises-go/exercise4/judge v0.0.0-20241019003312-fda19e97708b/go.mod h1:vVB0HyOSf4pmpiPGTaPuJTVmz/vz9aOPOcodFfRV56c= diff --git a/exercise4/bot/internal/client/main.go b/exercise4/bot/internal/client/main.go new file mode 100644 index 00000000..4501960c --- /dev/null +++ b/exercise4/bot/internal/client/main.go @@ -0,0 +1,50 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "os" +) + +var ( + lcl = "http://127.0.0.1" + port = os.Getenv("PORT") + url = fmt.Sprintf("%s:%s", lcl, port) + name = os.Getenv("NAME") +) + +func SendJoinRequest(ctx context.Context) { + data := map[string]string{ + "name": name, + "url": url, + } + + dataJson, err := json.Marshal(data) + if err != nil { + slog.Error("marshal error", data) + } + + serverUrl := fmt.Sprintf("%s:4444/join", lcl) + req, err := http.NewRequestWithContext(ctx, "POST", serverUrl, bytes.NewBuffer(dataJson)) + if err != nil { + slog.Error("join request error", "error", err) + return + } + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + slog.Error("join request error do", "error", err) + return + } + fmt.Println("ok") + defer resp.Body.Close() + + response, err := io.ReadAll(resp.Body) + fmt.Println(string(response)) +} diff --git a/exercise4/bot/internal/game/tic_tac_toe.go b/exercise4/bot/internal/game/tic_tac_toe.go new file mode 100644 index 00000000..28846285 --- /dev/null +++ b/exercise4/bot/internal/game/tic_tac_toe.go @@ -0,0 +1,99 @@ +package game + +import "github.com/talgat-ruby/exercises-go/exercise4/bot/internal/types" + +const ( + PlayerX = "x" + PlayerO = "o" + Empty = " " +) + +func BestMove(request *types.MoveRequest) int { + + bestScore := -1000 + bestMove := -1 + + for i := 0; i < len(request.Board); i++ { + if request.Board[i] == Empty { + request.Board[i] = request.Token + score := minimax(request.Board, 0, false, request.Token) + request.Board[i] = Empty + + if score > bestScore { + bestScore = score + bestMove = i + } + } + } + + return bestMove +} + +func minimax(board []string, depth int, isMaximizing bool, token string) int { + opponent := PlayerO + if token == PlayerO { + opponent = PlayerX + } + + score := evaluate(board, token, opponent) + if score != 0 { + return score + } + if isBoardFull(board) { + return 0 + } + + if isMaximizing { + bestScore := -1000 + for i := 0; i < len(board); i++ { + if board[i] == Empty { + board[i] = token + score := minimax(board, depth+1, false, token) + board[i] = Empty + bestScore = max(score, bestScore) + } + } + return bestScore + } else { + bestScore := 1000 + for i := 0; i < len(board); i++ { + if board[i] == Empty { + board[i] = opponent + score := minimax(board, depth+1, true, token) + board[i] = Empty + bestScore = min(score, bestScore) + } + } + return bestScore + } +} + +// Оценка игрового поля +func evaluate(board []string, player string, opponent string) int { + winPatterns := [][]int{ + {0, 3, 6}, {1, 4, 7}, {2, 5, 8}, // вертикальные линии для победы + {0, 1, 2}, {3, 4, 5}, {6, 7, 8}, // горизонтальные линии для победы + {0, 4, 8}, {2, 4, 6}, // диагонали для победы + } + + for _, pattern := range winPatterns { + if board[pattern[0]] == player && board[pattern[1]] == player && board[pattern[2]] == player { + return 10 + } + if board[pattern[0]] == opponent && board[pattern[1]] == opponent && board[pattern[2]] == opponent { + return -10 + } + } + + return 0 +} + +// Проверка, заполнено ли игровое поле +func isBoardFull(board []string) bool { + for _, cell := range board { + if cell == Empty { + return false + } + } + return true +} diff --git a/exercise4/bot/internal/types/types.go b/exercise4/bot/internal/types/types.go new file mode 100644 index 00000000..d20d5978 --- /dev/null +++ b/exercise4/bot/internal/types/types.go @@ -0,0 +1,10 @@ +package types + +type MoveRequest struct { + Board []string `json:"board"` + Token string `json:"token"` +} + +type ResponseMove struct { + Index int `json:"index"` +} diff --git a/exercise4/bot/main.go b/exercise4/bot/main.go index 64f9e0a3..4cac747f 100644 --- a/exercise4/bot/main.go +++ b/exercise4/bot/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "github.com/talgat-ruby/exercises-go/exercise4/bot/internal/client" "os" "os/signal" "syscall" @@ -13,7 +14,7 @@ func main() { ready := startServer() <-ready - // TODO after server start + client.SendJoinRequest(ctx) stop := make(chan os.Signal, 1) signal.Notify(stop, os.Interrupt, syscall.SIGTERM) diff --git a/exercise4/bot/server.go b/exercise4/bot/server.go index e6760ec5..fab24a3f 100644 --- a/exercise4/bot/server.go +++ b/exercise4/bot/server.go @@ -1,8 +1,12 @@ package main import ( + "encoding/json" "errors" "fmt" + "github.com/talgat-ruby/exercises-go/exercise4/bot/internal/game" + "github.com/talgat-ruby/exercises-go/exercise4/bot/internal/types" + "io" "net" "net/http" "os" @@ -21,6 +25,40 @@ func (l *readyListener) Accept() (net.Conn, error) { return l.Listener.Accept() } +func pingHandler(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("pong")) +} + +func moveHandler(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + bodyBytes, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Failed to read request body", http.StatusBadRequest) + return + } + + var moveReq types.MoveRequest + err = json.Unmarshal(bodyBytes, &moveReq) + if err != nil { + http.Error(w, "Invalid JSON format", http.StatusBadRequest) + return + } + + cell := game.BestMove(&moveReq) + resp := types.ResponseMove{Index: cell} + + responseData, err := json.Marshal(resp) + if err != nil { + http.Error(w, "Failed to marshal response", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(responseData) +} + func startServer() <-chan struct{} { ready := make(chan struct{}) @@ -34,12 +72,14 @@ func startServer() <-chan struct{} { IdleTimeout: 2 * time.Minute, } + http.HandleFunc("/ping", pingHandler) + http.HandleFunc("/move", moveHandler) + go func() { err := srv.Serve(list) if !errors.Is(err, http.ErrServerClosed) { panic(err) } }() - return ready } diff --git a/exercise5/problem1/problem1.go b/exercise5/problem1/problem1.go index 4f514fab..0bba2e88 100644 --- a/exercise5/problem1/problem1.go +++ b/exercise5/problem1/problem1.go @@ -1,9 +1,12 @@ package problem1 func incrementConcurrently(num int) int { + ch := make(chan int) + defer close(ch) go func() { num++ + ch <- 1 }() - + <-ch return num } diff --git a/exercise5/problem3/problem3.go b/exercise5/problem3/problem3.go index e085a51a..6ff68d9a 100644 --- a/exercise5/problem3/problem3.go +++ b/exercise5/problem3/problem3.go @@ -2,10 +2,11 @@ package problem3 func sum(a, b int) int { var c int - + ch := make(chan int) go func(a, b int) { c = a + b + ch <- c }(a, b) - + c = <-ch return c } diff --git a/exercise5/problem4/problem4.go b/exercise5/problem4/problem4.go index b5899ddf..f8457faa 100644 --- a/exercise5/problem4/problem4.go +++ b/exercise5/problem4/problem4.go @@ -1,6 +1,7 @@ package problem4 func iter(ch chan<- int, nums []int) { + defer close(ch) for _, n := range nums { ch <- n } diff --git a/exercise5/problem5/problem5.go b/exercise5/problem5/problem5.go index ac192c58..49190e86 100644 --- a/exercise5/problem5/problem5.go +++ b/exercise5/problem5/problem5.go @@ -1,8 +1,22 @@ package problem5 -func producer() {} +func producer(list []string, ch chan<- string) { + for _, v := range list { + ch <- v + } + close(ch) +} -func consumer() {} +func consumer(ch <-chan string) string { + var text string + for v := range ch { + if text != "" { + text += " " + } + text = text + v + } + return text +} func send( words []string, diff --git a/exercise5/problem7/problem7.go b/exercise5/problem7/problem7.go index c3c1d0c9..45416cb3 100644 --- a/exercise5/problem7/problem7.go +++ b/exercise5/problem7/problem7.go @@ -1,3 +1,13 @@ package problem7 -func multiplex(ch1 <-chan string, ch2 <-chan string) []string {} +func multiplex(ch1 <-chan string, ch2 <-chan string) []string { + var result []string + for v := range ch1 { + result = append(result, v) + } + + for v := range ch2 { + result = append(result, v) + } + return result +} diff --git a/exercise5/problem8/problem8.go b/exercise5/problem8/problem8.go index 3e951b3b..5836c465 100644 --- a/exercise5/problem8/problem8.go +++ b/exercise5/problem8/problem8.go @@ -4,4 +4,13 @@ import ( "time" ) -func withTimeout(ch <-chan string, ttl time.Duration) string {} +func withTimeout(ch <-chan string, ttl time.Duration) string { + for { + select { + case v := <-ch: + return v + case <-time.After(ttl): + return "timeout" + } + } +} diff --git a/exercise6/problem1/problem1.go b/exercise6/problem1/problem1.go index ee453b24..27d18154 100644 --- a/exercise6/problem1/problem1.go +++ b/exercise6/problem1/problem1.go @@ -1,9 +1,27 @@ package problem1 +import "sync" + type bankAccount struct { blnc int + m *sync.Mutex } func newAccount(blnc int) *bankAccount { - return &bankAccount{blnc} + m := sync.Mutex{} + return &bankAccount{blnc, &m} +} + +func (b *bankAccount) withdraw(balance int) { + b.m.Lock() + if b.blnc > 10 { + b.blnc = b.blnc - balance + } + b.m.Unlock() +} + +func (b *bankAccount) deposit(balance int) { + b.m.Lock() + b.blnc = b.blnc + balance + b.m.Unlock() } diff --git a/exercise6/problem2/problem2.go b/exercise6/problem2/problem2.go index 97e02368..a7eda3d1 100644 --- a/exercise6/problem2/problem2.go +++ b/exercise6/problem2/problem2.go @@ -1,6 +1,7 @@ package problem2 import ( + "sync" "time" ) @@ -8,13 +9,29 @@ var readDelay = 10 * time.Millisecond type bankAccount struct { blnc int + m *sync.Mutex } func newAccount(blnc int) *bankAccount { - return &bankAccount{blnc} + m := sync.Mutex{} + return &bankAccount{blnc, &m} } func (b *bankAccount) balance() int { time.Sleep(readDelay) - return 0 + return b.blnc +} + +func (b *bankAccount) withdraw(balance int) { + b.m.Lock() + if b.blnc > 10 { + b.blnc = b.blnc - balance + } + b.m.Unlock() +} + +func (b *bankAccount) deposit(balance int) { + b.m.Lock() + b.blnc = b.blnc + balance + b.m.Unlock() } diff --git a/exercise6/problem3/problem3.go b/exercise6/problem3/problem3.go index b34b90bb..5c1b08f3 100644 --- a/exercise6/problem3/problem3.go +++ b/exercise6/problem3/problem3.go @@ -1,5 +1,7 @@ package problem3 +import "sync/atomic" + type counter struct { val int64 } @@ -9,3 +11,15 @@ func newCounter() *counter { val: 0, } } + +func (c *counter) inc() { + atomic.AddInt64(&c.val, 1) +} + +func (c *counter) dec() { + atomic.AddInt64(&c.val, -1) +} + +func (c *counter) value() int64 { + return c.val +} diff --git a/exercise6/problem4/problem4.go b/exercise6/problem4/problem4.go index 793449c9..ed7e3b60 100644 --- a/exercise6/problem4/problem4.go +++ b/exercise6/problem4/problem4.go @@ -1,31 +1,36 @@ package problem4 import ( + "sync" "time" ) -func worker(id int, _ *[]string, ch chan<- int) { - // TODO wait for shopping list to be completed +func worker(id int, shoppingList *[]string, c *sync.Cond, ch chan<- int) { + c.L.Lock() + defer c.L.Unlock() + for len(*shoppingList) == 0 { + c.Wait() + } ch <- id } - -func updateShopList(shoppingList *[]string) { +func updateShopList(shoppingList *[]string, c *sync.Cond) { time.Sleep(10 * time.Millisecond) - + c.L.Lock() + defer c.L.Unlock() *shoppingList = append(*shoppingList, "apples") *shoppingList = append(*shoppingList, "milk") *shoppingList = append(*shoppingList, "bake soda") + c.Signal() } func notifyOnShopListUpdate(shoppingList *[]string, numWorkers int) <-chan int { notifier := make(chan int) - + m := sync.Mutex{} + newCond := sync.NewCond(&m) for i := range numWorkers { - go worker(i+1, shoppingList, notifier) + go worker(i+1, shoppingList, newCond, notifier) time.Sleep(time.Millisecond) // order matters } - - go updateShopList(shoppingList) - + go updateShopList(shoppingList, newCond) return notifier } diff --git a/exercise6/problem5/problem5.go b/exercise6/problem5/problem5.go index 8e4a1703..077d4ac0 100644 --- a/exercise6/problem5/problem5.go +++ b/exercise6/problem5/problem5.go @@ -1,31 +1,41 @@ package problem5 import ( + "sync" "time" ) -func worker(id int, shoppingList *[]string, ch chan<- int) { - // TODO wait for shopping list to be completed +func worker(id int, shoppingList *[]string, c *sync.Cond, ch chan<- int) { + c.L.Lock() + defer c.L.Unlock() + for len(*shoppingList) == 0 { + c.Wait() + } ch <- id } -func updateShopList(shoppingList *[]string) { +func updateShopList(shoppingList *[]string, c *sync.Cond) { time.Sleep(10 * time.Millisecond) + c.L.Lock() + defer c.L.Unlock() *shoppingList = append(*shoppingList, "apples") *shoppingList = append(*shoppingList, "milk") *shoppingList = append(*shoppingList, "bake soda") + + c.Broadcast() } func notifyOnShopListUpdate(shoppingList *[]string, numWorkers int) <-chan int { notifier := make(chan int) - + m := sync.Mutex{} + newCond := sync.NewCond(&m) for i := range numWorkers { - go worker(i+1, shoppingList, notifier) + go worker(i+1, shoppingList, newCond, notifier) time.Sleep(time.Millisecond) // order matters } - go updateShopList(shoppingList) + go updateShopList(shoppingList, newCond) return notifier } diff --git a/exercise6/problem7/problem7.go b/exercise6/problem7/problem7.go index ef49497b..0ea037b2 100644 --- a/exercise6/problem7/problem7.go +++ b/exercise6/problem7/problem7.go @@ -1,21 +1,32 @@ package problem7 import ( - "fmt" "math/rand" + "sync" "time" ) func task() { - start := time.Now() + var m sync.Mutex var t *time.Timer + + resetTimer := func() { + m.Lock() + defer m.Unlock() + if t != nil { + t.Reset(randomDuration()) + } + } + + m.Lock() t = time.AfterFunc( randomDuration(), func() { - fmt.Println(time.Now().Sub(start)) - t.Reset(randomDuration()) + resetTimer() }, ) + m.Unlock() + time.Sleep(5 * time.Second) } diff --git a/exercise6/problem8/problem8.go b/exercise6/problem8/problem8.go index 949eb2d2..fea87ccc 100644 --- a/exercise6/problem8/problem8.go +++ b/exercise6/problem8/problem8.go @@ -1,3 +1,24 @@ package problem8 -func multiplex(chs []<-chan string) []string {} +import "sync" + +func multiplex(chs []<-chan string) []string { + var result []string + var wg sync.WaitGroup + var mux sync.Mutex + + for _, ch := range chs { + wg.Add(1) + go func(ch <-chan string) { + defer wg.Done() + for msg := range ch { + mux.Lock() + result = append(result, msg) + mux.Unlock() + } + }(ch) + } + + wg.Wait() + return result +} diff --git a/exercise7/blogging-platform/.dockerignore b/exercise7/blogging-platform/.dockerignore new file mode 100644 index 00000000..9e03c484 --- /dev/null +++ b/exercise7/blogging-platform/.dockerignore @@ -0,0 +1,32 @@ +# Include any files or directories that you don't want to be copied to your +# container here (e.g., local build artifacts, temporary files, etc.). +# +# For more help, visit the .dockerignore file reference guide at +# https://docs.docker.com/go/build-context-dockerignore/ + +**/.DS_Store +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/bin +**/charts +**/docker-compose* +**/compose.y*ml +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md diff --git a/exercise7/blogging-platform/.env b/exercise7/blogging-platform/.env new file mode 100644 index 00000000..b227b471 --- /dev/null +++ b/exercise7/blogging-platform/.env @@ -0,0 +1,4 @@ +POSTGRES_DB=postgres +POSTGRES_USER=root +POSTGRES_PASSWORD=root +POSTGRES_URL='postgres://root:root@localhost:54322/postgres?sslmode=disable' \ No newline at end of file diff --git a/exercise7/blogging-platform/.env.example b/exercise7/blogging-platform/.env.example new file mode 100644 index 00000000..88134069 --- /dev/null +++ b/exercise7/blogging-platform/.env.example @@ -0,0 +1,6 @@ +DB_NAME=postgres +DB_USER=root +DB_PASSWORD=root +POSTGRES_URL='postgres://root:root@localhost:5432/postgres?sslmode=disable' +DB_PORT=5432 +DB_HOST=127.0.0.1 \ No newline at end of file diff --git a/exercise7/blogging-platform/.gitignore b/exercise7/blogging-platform/.gitignore new file mode 100644 index 00000000..b9690d5f --- /dev/null +++ b/exercise7/blogging-platform/.gitignore @@ -0,0 +1 @@ +./.env \ No newline at end of file diff --git a/exercise7/blogging-platform/Blogs.postman_collection.json b/exercise7/blogging-platform/Blogs.postman_collection.json new file mode 100644 index 00000000..dc3f5518 --- /dev/null +++ b/exercise7/blogging-platform/Blogs.postman_collection.json @@ -0,0 +1,154 @@ +{ + "info": { + "_postman_id": "cc87dc3b-23a2-4122-bfd2-ca454eab6f92", + "name": "Blogs", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "19738142" + }, + "item": [ + { + "name": "GetAll", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{url}}/blogs", + "host": [ + "{{url}}" + ], + "path": [ + "blogs" + ] + } + }, + "response": [] + }, + { + "name": "GetByID", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{url}}/blog/1", + "host": [ + "{{url}}" + ], + "path": [ + "blog", + "1" + ] + } + }, + "response": [] + }, + { + "name": "CreateBlog", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"test1\",\n \"description\": \"test test test\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{url}}/blog", + "host": [ + "{{url}}" + ], + "path": [ + "blog" + ] + } + }, + "response": [] + }, + { + "name": "UpdateBlog", + "request": { + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"test1\",\n \"description\": \"test test test\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{url}}/blog/1", + "host": [ + "{{url}}" + ], + "path": [ + "blog", + "1" + ] + } + }, + "response": [] + }, + { + "name": "UpdateBlog Copy", + "request": { + "method": "DELETE", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"test1\",\n \"description\": \"test test test\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{url}}/blog/1", + "host": [ + "{{url}}" + ], + "path": [ + "blog", + "1" + ] + } + }, + "response": [] + } + ], + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "packages": {}, + "exec": [ + "" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "packages": {}, + "exec": [ + "" + ] + } + } + ], + "variable": [ + { + "key": "url", + "value": "127.0.0.1:8080", + "type": "string" + } + ] +} \ No newline at end of file diff --git a/exercise7/blogging-platform/Dockerfile b/exercise7/blogging-platform/Dockerfile new file mode 100644 index 00000000..ae05a6eb --- /dev/null +++ b/exercise7/blogging-platform/Dockerfile @@ -0,0 +1,44 @@ +ARG GO_VERSION=1.23.3 +FROM --platform=$BUILDPLATFORM golang:${GO_VERSION} AS build +WORKDIR /src + +RUN --mount=type=cache,target=/go/pkg/mod/ \ + --mount=type=bind,source=go.sum,target=go.sum \ + --mount=type=bind,source=go.mod,target=go.mod \ + go mod download -x + +ARG TARGETARCH + +RUN --mount=type=cache,target=/go/pkg/mod/ \ + --mount=type=bind,target=. \ + CGO_ENABLED=0 GOARCH=$TARGETARCH go build -o /bin/server . + +FROM debian:bullseye-slim AS final + +# Обновление списка пакетов и установка необходимых пакетов +RUN --mount=type=cache,target=/var/cache/apt \ + apt-get update && \ + apt-get install -y --no-install-recommends \ + ca-certificates \ + tzdata \ + curl \ + && \ + update-ca-certificates + +ARG UID=10001 +RUN adduser \ + --disabled-password \ + --gecos "" \ + --home "/nonexistent" \ + --shell "/sbin/nologin" \ + --no-create-home \ + --uid "${UID}" \ + appuser + +USER appuser + +COPY --from=build /bin/server /bin/ + +EXPOSE 8080 + +ENTRYPOINT [ "/bin/server" ] \ No newline at end of file diff --git a/exercise7/blogging-platform/Makefile b/exercise7/blogging-platform/Makefile new file mode 100644 index 00000000..a4e7202d --- /dev/null +++ b/exercise7/blogging-platform/Makefile @@ -0,0 +1,10 @@ +ifneq (,$(wildcard .env)) + include .env + export +endif + +up: + migrate -database $(POSTGRES_URL) -path db/migrations/ up + +down: + migrate -database $(POSTGRES_URL) -path db/migrations/ down \ No newline at end of file diff --git a/exercise7/blogging-platform/README.Docker.md b/exercise7/blogging-platform/README.Docker.md new file mode 100644 index 00000000..3bf66ee3 --- /dev/null +++ b/exercise7/blogging-platform/README.Docker.md @@ -0,0 +1,22 @@ +### Building and running your application + +When you're ready, start your application by running: +`docker compose up --build`. + +Your application will be available at http://localhost:8080. + +### Deploying your application to the cloud + +First, build your image, e.g.: `docker build -t myapp .`. +If your cloud uses a different CPU architecture than your development +machine (e.g., you are on a Mac M1 and your cloud provider is amd64), +you'll want to build the image for that platform, e.g.: +`docker build --platform=linux/amd64 -t myapp .`. + +Then, push it to your registry, e.g. `docker push myregistry.com/myapp`. + +Consult Docker's [getting started](https://docs.docker.com/go/get-started-sharing/) +docs for more detail on building and pushing. + +### References +* [Docker's Go guide](https://docs.docker.com/language/golang/) \ No newline at end of file diff --git a/exercise7/blogging-platform/README.md b/exercise7/blogging-platform/README.md new file mode 100644 index 00000000..e6ef7017 --- /dev/null +++ b/exercise7/blogging-platform/README.md @@ -0,0 +1,3 @@ +# Blogging Platform + +Please check https://roadmap.sh/projects/blogging-platform-api. diff --git a/exercise7/blogging-platform/compose.yaml b/exercise7/blogging-platform/compose.yaml new file mode 100644 index 00000000..99b39795 --- /dev/null +++ b/exercise7/blogging-platform/compose.yaml @@ -0,0 +1,29 @@ +services: +# server: +# build: +# context: . +# target: final +# ports: +# - 8080:8080 + db: + image: postgres + container_name: blog_db + restart: always + user: postgres + volumes: + - db-data:/var/lib/postgresql/data + environment: + - POSTGRES_DB=postgres + - POSTGRES_USER=root + - POSTGRES_PASSWORD=root + - POSTGRES_URL='postgres://postgres:root@localhost:5432/postgres?sslmode=disable' + ports: + - 54322:5432 + healthcheck: + test: [ "CMD", "pg_isready" ] + interval: 10s + timeout: 5s + retries: 5 +volumes: + db-data: + diff --git a/exercise7/blogging-platform/db/migrations/000001_create_blogs_table.down.sql b/exercise7/blogging-platform/db/migrations/000001_create_blogs_table.down.sql new file mode 100644 index 00000000..ee21ccfc --- /dev/null +++ b/exercise7/blogging-platform/db/migrations/000001_create_blogs_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS blogs; \ No newline at end of file diff --git a/exercise7/blogging-platform/db/migrations/000001_create_blogs_table.up.sql b/exercise7/blogging-platform/db/migrations/000001_create_blogs_table.up.sql new file mode 100644 index 00000000..df9a21eb --- /dev/null +++ b/exercise7/blogging-platform/db/migrations/000001_create_blogs_table.up.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS blogs ( + id SERIAL PRIMARY KEY, + name VARCHAR NOT NULL, + description TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +) \ No newline at end of file diff --git a/exercise7/blogging-platform/go.mod b/exercise7/blogging-platform/go.mod new file mode 100644 index 00000000..f76ff593 --- /dev/null +++ b/exercise7/blogging-platform/go.mod @@ -0,0 +1,11 @@ +module github.com/talgat-ruby/exercises-go/exercise7/blogging-platform + +go 1.23.3 + +require ( + github.com/joho/godotenv v1.5.1 + github.com/lib/pq v1.10.9 // indirect + github.com/sethvargo/go-envconfig v1.1.0 +) + +require github.com/golang-migrate/migrate v3.5.4+incompatible // indirect diff --git a/exercise7/blogging-platform/go.sum b/exercise7/blogging-platform/go.sum new file mode 100644 index 00000000..dfda0a2f --- /dev/null +++ b/exercise7/blogging-platform/go.sum @@ -0,0 +1,10 @@ +github.com/golang-migrate/migrate v3.5.4+incompatible h1:R7OzwvCJTCgwapPCiX6DyBiu2czIUMDCB118gFTKTUA= +github.com/golang-migrate/migrate v3.5.4+incompatible/go.mod h1:IsVUlFN5puWOmXrqjgGUfIRIbU7mr8oNBE2tyERd9Wk= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/sethvargo/go-envconfig v1.1.0 h1:cWZiJxeTm7AlCvzGXrEXaSTCNgip5oJepekh/BOQuog= +github.com/sethvargo/go-envconfig v1.1.0/go.mod h1:JLd0KFWQYzyENqnEPWWZ49i4vzZo/6nRidxI8YvGiHw= diff --git a/exercise7/blogging-platform/internal/api/handler/blogs/create_blog.go b/exercise7/blogging-platform/internal/api/handler/blogs/create_blog.go new file mode 100644 index 00000000..8cf6542c --- /dev/null +++ b/exercise7/blogging-platform/internal/api/handler/blogs/create_blog.go @@ -0,0 +1,45 @@ +package blogs + +import ( + "encoding/json" + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/internal/db/blog" + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/pkg/httputils/response" + "net/http" +) + +type CreateBlogRequest struct { + Data *blog.BlogModel `json:"data"` +} + +type CreateBlogResponse struct { + Data *blog.BlogModel `json:"data"` +} + +func (b *Blogs) AddBlog(w http.ResponseWriter, r *http.Request) { + b.logger.Debug("method", "Create blog") + + decoder := json.NewDecoder(r.Body) + rBody := blog.BlogModel{} + err := decoder.Decode(&rBody) + if err != nil { + b.logger.Error("Error in request body") + return + } + + createdBlog, err := b.db.AddBlog(&rBody) + if err != nil { + b.logger.Error( + "failed to query from db", + "error", err, + ) + http.Error(w, "failed to query from db", http.StatusInternalServerError) + return + } + + response.JSON( + w, + http.StatusOK, + createdBlog, + ) + return +} diff --git a/exercise7/blogging-platform/internal/api/handler/blogs/delete_blog.go b/exercise7/blogging-platform/internal/api/handler/blogs/delete_blog.go new file mode 100644 index 00000000..018e90ca --- /dev/null +++ b/exercise7/blogging-platform/internal/api/handler/blogs/delete_blog.go @@ -0,0 +1,47 @@ +package blogs + +import ( + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/internal/db/blog" + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/pkg/httputils/response" + "net/http" + "strconv" +) + +type DeleteBlogRequest struct { + Data *blog.BlogModel `json:"data"` +} + +type DeleteBlogResponse struct { + Data *blog.BlogModel `json:"data"` +} + +func (b *Blogs) DeleteBlog(w http.ResponseWriter, r *http.Request) { + b.logger.Info("method", "Delete blog") + idStr := r.PathValue("id") + id, err := strconv.Atoi(idStr) + if err != nil { + b.logger.Error( + "failed to convert id to int", + "error", err, + ) + http.Error(w, "failed to convert id to int", http.StatusBadRequest) + return + } + + err = b.db.DeleteBlog(id) + if err != nil { + b.logger.Error( + "failed to query from db", + "error", err, + ) + http.Error(w, "failed to query from db", http.StatusInternalServerError) + return + } + + response.JSON( + w, + http.StatusOK, + "successfully deleted", + ) + return +} diff --git a/exercise7/blogging-platform/internal/api/handler/blogs/get_blog.go b/exercise7/blogging-platform/internal/api/handler/blogs/get_blog.go new file mode 100644 index 00000000..50363b76 --- /dev/null +++ b/exercise7/blogging-platform/internal/api/handler/blogs/get_blog.go @@ -0,0 +1,37 @@ +package blogs + +import ( + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/pkg/httputils/response" + "net/http" + "strconv" +) + +func (b *Blogs) GetBlogById(w http.ResponseWriter, r *http.Request) { + b.logger.Debug("method", "Find blog") + idStr := r.PathValue("id") + id, err := strconv.Atoi(idStr) + if err != nil { + b.logger.Error( + "failed to convert id to int", + "error", err, + ) + http.Error(w, "failed to convert id to int", http.StatusBadRequest) + return + } + + blog, err := b.db.GetBlogById(id) + if err != nil { + b.logger.Error( + "Blog not found by id", + "error", err, + ) + http.Error(w, "Blog not found by id", http.StatusBadRequest) + return + } + response.JSON( + w, + http.StatusOK, + blog, + ) + return +} diff --git a/exercise7/blogging-platform/internal/api/handler/blogs/get_blogs.go b/exercise7/blogging-platform/internal/api/handler/blogs/get_blogs.go new file mode 100644 index 00000000..fca6bf8d --- /dev/null +++ b/exercise7/blogging-platform/internal/api/handler/blogs/get_blogs.go @@ -0,0 +1,27 @@ +package blogs + +import ( + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/pkg/httputils/response" + "net/http" +) + +func (b *Blogs) GetBlogs(w http.ResponseWriter, r *http.Request) { + _ = b.logger.With("method", "Find blog") + offset := 0 + limit := 10 + blogs, err := b.db.GetBlogs(offset, limit) + if err != nil { + b.logger.Error( + "failed to convert id to int", + "error", err, + ) + http.Error(w, "failed to convert id to int", http.StatusBadRequest) + return + } + response.JSON( + w, + http.StatusOK, + blogs, + ) + return +} diff --git a/exercise7/blogging-platform/internal/api/handler/blogs/main.go b/exercise7/blogging-platform/internal/api/handler/blogs/main.go new file mode 100644 index 00000000..8ccbed40 --- /dev/null +++ b/exercise7/blogging-platform/internal/api/handler/blogs/main.go @@ -0,0 +1,15 @@ +package blogs + +import ( + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/internal/db" + "log/slog" +) + +type Blogs struct { + logger *slog.Logger + db *db.ConfDB +} + +func New(logger *slog.Logger, db *db.ConfDB) *Blogs { + return &Blogs{logger: logger, db: db} +} diff --git a/exercise7/blogging-platform/internal/api/handler/blogs/update_blog.go b/exercise7/blogging-platform/internal/api/handler/blogs/update_blog.go new file mode 100644 index 00000000..29a15522 --- /dev/null +++ b/exercise7/blogging-platform/internal/api/handler/blogs/update_blog.go @@ -0,0 +1,55 @@ +package blogs + +import ( + "encoding/json" + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/internal/db/blog" + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/pkg/httputils/response" + "net/http" + "strconv" +) + +type UpdateBlogRequest struct { + Data *blog.BlogModel `json:"data"` +} + +type UpdateBlogResponse struct { + Data *blog.BlogModel `json:"data"` +} + +func (b *Blogs) UpdateBlog(w http.ResponseWriter, r *http.Request) { + b.logger.Debug("method", "Update blog") + idStr := r.PathValue("id") + id, err := strconv.Atoi(idStr) + if err != nil { + b.logger.Error( + "failed to convert id to int", + "error", err, + ) + http.Error(w, "failed to convert id to int", http.StatusBadRequest) + return + } + decoder := json.NewDecoder(r.Body) + rBody := blog.BlogModel{} + err = decoder.Decode(&rBody) + if err != nil { + b.logger.Error("Error in request body") + return + } + + err = b.db.UpdateBlog(id, &rBody) + if err != nil { + b.logger.Error( + "failed to query from db", + "error", err, + ) + http.Error(w, "failed to query from db", http.StatusInternalServerError) + return + } + + response.JSON( + w, + http.StatusOK, + "successfully updated", + ) + return +} diff --git a/exercise7/blogging-platform/internal/api/handler/main.go b/exercise7/blogging-platform/internal/api/handler/main.go new file mode 100644 index 00000000..b9e4814e --- /dev/null +++ b/exercise7/blogging-platform/internal/api/handler/main.go @@ -0,0 +1,20 @@ +package handler + +import ( + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/internal/api/handler/blogs" + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/internal/db" + "log/slog" +) + +type Handler struct { + *blogs.Blogs +} + +func NewHandler(logger *slog.Logger, db *db.ConfDB) *Handler { + return &Handler{ + blogs.New( + logger, + db, + ), + } +} diff --git a/exercise7/blogging-platform/internal/api/main.go b/exercise7/blogging-platform/internal/api/main.go new file mode 100644 index 00000000..3142d327 --- /dev/null +++ b/exercise7/blogging-platform/internal/api/main.go @@ -0,0 +1,43 @@ +package api + +import ( + "context" + "errors" + "fmt" + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/internal/api/handler" + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/internal/api/router" + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/internal/db" + "log/slog" + "net" + "net/http" +) + +type Api struct { + logger *slog.Logger + router *router.Router +} + +func NewApi(logger *slog.Logger, db *db.ConfDB) *Api { + h := handler.NewHandler(logger, db) + r := router.NewRouter(h) + return &Api{ + logger: slog.With("service", "api"), + router: r, + } +} + +func (a *Api) Start(ctx context.Context) error { + mux := a.router.Start(ctx) + + srv := &http.Server{ + Addr: ":8080", + Handler: mux, + BaseContext: func(net.Listener) context.Context { return ctx }, + } + + fmt.Println("Staring server on :8080") + if err := srv.ListenAndServe(); err != nil && errors.Is(err, http.ErrServerClosed) { + return err + } + return nil +} diff --git a/exercise7/blogging-platform/internal/api/router/blogs.go b/exercise7/blogging-platform/internal/api/router/blogs.go new file mode 100644 index 00000000..8d4e6047 --- /dev/null +++ b/exercise7/blogging-platform/internal/api/router/blogs.go @@ -0,0 +1,13 @@ +package router + +import ( + "context" +) + +func (r *Router) blogs(ctx context.Context) { + r.router.HandleFunc("GET /blogs", r.handler.GetBlogs) + r.router.HandleFunc("GET /blog/{id}", r.handler.GetBlogById) + r.router.HandleFunc("POST /blog", r.handler.AddBlog) + r.router.HandleFunc("PUT /blog/{id}", r.handler.UpdateBlog) + r.router.HandleFunc("DELETE /blog/{id}", r.handler.DeleteBlog) +} diff --git a/exercise7/blogging-platform/internal/api/router/main.go b/exercise7/blogging-platform/internal/api/router/main.go new file mode 100644 index 00000000..e9a56cf3 --- /dev/null +++ b/exercise7/blogging-platform/internal/api/router/main.go @@ -0,0 +1,26 @@ +package router + +import ( + "context" + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/internal/api/handler" + "net/http" +) + +type Router struct { + router *http.ServeMux + handler *handler.Handler +} + +func NewRouter(handler *handler.Handler) *Router { + mux := http.NewServeMux() + + return &Router{ + router: mux, + handler: handler, + } +} + +func (r *Router) Start(ctx context.Context) *http.ServeMux { + r.blogs(ctx) + return r.router +} diff --git a/exercise7/blogging-platform/internal/config/config.go b/exercise7/blogging-platform/internal/config/config.go new file mode 100644 index 00000000..0faa5e9a --- /dev/null +++ b/exercise7/blogging-platform/internal/config/config.go @@ -0,0 +1,30 @@ +package config + +import ( + "context" + "flag" + "github.com/joho/godotenv" +) + +type SharedConfig struct { + Port int `env:"PORT"` + Host string `env:"HOST,default=localhost"` +} + +type Config struct { + DB *DBConfig +} + +func NewConfig(ctx context.Context) (*Config, error) { + config := &Config{} + if err := godotenv.Load(".env"); err != nil { + return nil, err + } + if c, err := NewConfigDB(ctx); err != nil { + return nil, err + } else { + config.DB = c + } + flag.Parse() + return config, nil +} diff --git a/exercise7/blogging-platform/internal/config/service_db.go b/exercise7/blogging-platform/internal/config/service_db.go new file mode 100644 index 00000000..630d924b --- /dev/null +++ b/exercise7/blogging-platform/internal/config/service_db.go @@ -0,0 +1,29 @@ +package config + +import ( + "context" + "flag" + "github.com/sethvargo/go-envconfig" +) + +type DBConfig struct { + *SharedConfig `env:",prefix=DB_"` + User string `env:"DB_USER"` + Password string `env:"DB_PASSWORD"` + DBName string `env:"DB_NAME"` +} + +func NewConfigDB(ctx context.Context) (*DBConfig, error) { + c := &DBConfig{} + + if err := envconfig.Process(ctx, c); err != nil { + return nil, err + } + + flag.StringVar(&c.Host, "db-host", c.Host, "database host [DB_HOST]") + flag.IntVar(&c.Port, "db-port", c.Port, "database port [DB_PORT]") + flag.StringVar(&c.User, "db-user", c.User, "database user [DB_USER]") + flag.StringVar(&c.Password, "db-password", c.Password, "database password [DB_PASSWORD]") + flag.StringVar(&c.DBName, "db-name", c.DBName, "database name [DB_NAME]") + return c, nil +} diff --git a/exercise7/blogging-platform/internal/db/blog/main.go b/exercise7/blogging-platform/internal/db/blog/main.go new file mode 100644 index 00000000..782fa5ee --- /dev/null +++ b/exercise7/blogging-platform/internal/db/blog/main.go @@ -0,0 +1,18 @@ +package blog + +import ( + "database/sql" + "log/slog" +) + +type Blogs struct { + logger *slog.Logger + db *sql.DB +} + +func NewBlog(logger *slog.Logger, db *sql.DB) *Blogs { + return &Blogs{ + logger: logger, + db: db, + } +} diff --git a/exercise7/blogging-platform/internal/db/blog/model.go b/exercise7/blogging-platform/internal/db/blog/model.go new file mode 100644 index 00000000..bd25d197 --- /dev/null +++ b/exercise7/blogging-platform/internal/db/blog/model.go @@ -0,0 +1,11 @@ +package blog + +import "time" + +type BlogModel struct { + ID int `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + CreatedAt *time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at"` +} diff --git a/exercise7/blogging-platform/internal/db/blog/repository.go b/exercise7/blogging-platform/internal/db/blog/repository.go new file mode 100644 index 00000000..af57da92 --- /dev/null +++ b/exercise7/blogging-platform/internal/db/blog/repository.go @@ -0,0 +1,127 @@ +package blog + +import "fmt" + +func (b *Blogs) GetBlogs(offset, limit int) ([]BlogModel, error) { + query := `SELECT * FROM blogs OFFSET $1 LIMIT $2` + + rows, err := b.db.Query(query, offset, limit) + if err != nil { + b.logger.Error("fail to query table movie", "error", err) + return nil, err + } + + blogs := make([]BlogModel, 0) + defer rows.Close() + + for rows.Next() { + blog := BlogModel{} + if err := rows.Scan( + &blog.ID, + &blog.Name, + &blog.Description, + &blog.CreatedAt, + &blog.UpdatedAt, + ); err != nil { + b.logger.Error("fail to query table movie", "error", err) + return nil, err + } + blogs = append(blogs, blog) + } + + if err := rows.Err(); err != nil { + b.logger.Error("fail to scan rows", "error", err) + return nil, err + } + + return blogs, nil +} + +func (b *Blogs) GetBlogById(id int) (*BlogModel, error) { + query := `SELECT * FROM blogs WHERE id = $1` + + rows := b.db.QueryRow(query, id) + if err := rows.Err(); err != nil { + b.logger.Error("fail to query table movie", "error", err) + return nil, err + } + + blogs := BlogModel{} + if err := rows.Scan( + &blogs.ID, + &blogs.Name, + &blogs.Description, + &blogs.CreatedAt, + &blogs.UpdatedAt, + ); err != nil { + b.logger.Error("fail to query table movie", "error", err) + return nil, err + } + return &blogs, nil +} + +func (b *Blogs) AddBlog(data *BlogModel) (*BlogModel, error) { + query := `INSERT INTO blogs (name, description) VALUES ($1, $2) RETURNING id, name, description, created_at, updated_at` + + rows := b.db.QueryRow(query, data.Name, data.Description) + if err := rows.Err(); err != nil { + b.logger.Error("fail to query table movie", "error", err) + return nil, err + } + blogs := BlogModel{} + if err := rows.Scan( + &blogs.ID, + &blogs.Name, + &blogs.Description, + &blogs.CreatedAt, + &blogs.UpdatedAt, + ); err != nil { + b.logger.Error("fail to query table movie", "error", err) + return nil, err + } + return &blogs, nil +} + +func (b *Blogs) UpdateBlog(id int, data *BlogModel) error { + query := `UPDATE blogs SET name = $2, description = $3 WHERE id = $1` + + resp, err := b.db.Exec(query, id, data.Name, data.Description) + if err != nil { + b.logger.Error("fail to query table movie", "error", err) + return err + } + + num, err := resp.RowsAffected() + if err != nil { + b.logger.Error("failed to RowsAffected") + return fmt.Errorf("failed to RowsAffected") + } + if num == 0 { + b.logger.Error("blogs not found", "id", id) + return fmt.Errorf("blogs not found id = %d", id) + } + b.logger.Info("blogs successfully updated") + return nil +} + +func (b *Blogs) DeleteBlog(id int) error { + query := `DELETE FROM blogs WHERE id = $1` + + resp, err := b.db.Exec(query, id) + if err != nil { + b.logger.Error("fail to query table movie", "error", err) + return err + } + + num, err := resp.RowsAffected() + if err != nil { + b.logger.Error("failed to RowsAffected") + return fmt.Errorf("failed to RowsAffected") + } + if num == 0 { + b.logger.Error("blogs not found", "id", id) + return fmt.Errorf("blogs not found id = %d", id) + } + b.logger.Info("blogs successfully deleted") + return nil +} diff --git a/exercise7/blogging-platform/internal/db/db.go b/exercise7/blogging-platform/internal/db/db.go new file mode 100644 index 00000000..7d2dc322 --- /dev/null +++ b/exercise7/blogging-platform/internal/db/db.go @@ -0,0 +1,60 @@ +package db + +import ( + "database/sql" + "fmt" + _ "github.com/lib/pq" + conf "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/internal/config" + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/internal/db/blog" + "log/slog" + "os" + "strconv" +) + +type ConfDB struct { + Conf *conf.Config + DB *sql.DB + *blog.Blogs +} + +func New(conf *conf.Config, logger *slog.Logger) (*ConfDB, error) { + db, err := NewDb( + conf.DB.Host, + conf.DB.Port, + conf.DB.User, + conf.DB.Password, + conf.DB.DBName, + ) + if err != nil { + return nil, err + } + + return &ConfDB{ + Conf: conf, + DB: db, + Blogs: blog.NewBlog(logger, db), + }, nil +} + +func NewDb(host string, port int, user string, password string, dbname string) (*sql.DB, error) { + fmt.Println(host, port, user, password, dbname) + port, err := strconv.Atoi(os.Getenv("DB_PORT")) + if err != nil { + return nil, err + } + psqlInfo := fmt.Sprintf( + "host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", + host, port, user, password, dbname, + ) + db, err := sql.Open("postgres", psqlInfo) + + if err != nil { + return nil, err + } + + if err = db.Ping(); err != nil { + return nil, err + } + + return db, nil +} diff --git a/exercise7/blogging-platform/main.go b/exercise7/blogging-platform/main.go new file mode 100644 index 00000000..8e7d0d49 --- /dev/null +++ b/exercise7/blogging-platform/main.go @@ -0,0 +1,61 @@ +package main + +import ( + "context" + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/internal/api" + conf "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/internal/config" + db "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/internal/db" + "log/slog" + "os" + "os/signal" +) + +func main() { + ctx, cancel := context.WithCancel(context.Background()) + + //config + config, err := conf.NewConfig(ctx) + if err != nil { + slog.ErrorContext( + ctx, + "error on load config", + "service", "config", + "error", err, + ) + } + + // db + dbConnect, err := db.New(config, slog.With("service", "db")) + if err != nil { + slog.ErrorContext( + ctx, + "initialize service error", + "service", "db", + "error", err, + ) + panic(err) + } + + // api + a := api.NewApi(slog.With("service", "api"), dbConnect) + slog.InfoContext(ctx, "initialize service", "service", "api") + + go func(ctx context.Context, cancelFunc context.CancelFunc) { + if err := a.Start(ctx); err != nil { + slog.ErrorContext(ctx, "failed to start api", "error", err.Error()) + } + cancelFunc() + }(ctx, cancel) + + go func(cancelFunc context.CancelFunc) { + shutdown := make(chan os.Signal, 1) // Create channel to signify s signal being sent + signal.Notify(shutdown, os.Interrupt) // When an interrupt is sent, notify the channel + + sig := <-shutdown + slog.WarnContext(ctx, "signal received - shutting down...", "signal", sig) + + cancelFunc() + }(cancel) + + <-ctx.Done() +} diff --git a/exercise7/blogging-platform/pkg/httputils/request/body.go b/exercise7/blogging-platform/pkg/httputils/request/body.go new file mode 100644 index 00000000..92d639f4 --- /dev/null +++ b/exercise7/blogging-platform/pkg/httputils/request/body.go @@ -0,0 +1,76 @@ +package request + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/pkg/httputils/statusError" +) + +func JSON(w http.ResponseWriter, r *http.Request, dst interface{}) error { + ct := r.Header.Get("Content-Type") + if ct != "" { + mediaType := strings.ToLower(strings.TrimSpace(strings.Split(ct, ";")[0])) + if mediaType != "application/json" { + msg := "Content-Type header is not application/json" + return statusError.New(http.StatusUnsupportedMediaType, msg) + } + } + + r.Body = http.MaxBytesReader(w, r.Body, 1048576) + + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + + err := dec.Decode(&dst) + if err != nil { + var syntaxError *json.SyntaxError + var unmarshalTypeError *json.UnmarshalTypeError + + switch { + case errors.As(err, &syntaxError): + msg := fmt.Sprintf("Request body contains badly-formed JSON (at position %d)", syntaxError.Offset) + return statusError.New(http.StatusBadRequest, msg) + + case errors.Is(err, io.ErrUnexpectedEOF): + msg := "Request body contains badly-formed JSON" + return statusError.New(http.StatusBadRequest, msg) + + case errors.As(err, &unmarshalTypeError): + msg := fmt.Sprintf( + "Request body contains an invalid value for the %q field (at position %d)", + unmarshalTypeError.Field, + unmarshalTypeError.Offset, + ) + return statusError.New(http.StatusBadRequest, msg) + + case strings.HasPrefix(err.Error(), "json: unknown field "): + fieldName := strings.TrimPrefix(err.Error(), "json: unknown field ") + msg := fmt.Sprintf("Request body contains unknown field %s", fieldName) + return statusError.New(http.StatusBadRequest, msg) + + case errors.Is(err, io.EOF): + msg := "Request body must not be empty" + return statusError.New(http.StatusBadRequest, msg) + + case err.Error() == "http: request body too large": + msg := "Request body must not be larger than 1MB" + return statusError.New(http.StatusRequestEntityTooLarge, msg) + + default: + return err + } + } + + err = dec.Decode(&struct{}{}) + if !errors.Is(err, io.EOF) { + msg := "Request body must only contain a single JSON object" + return statusError.New(http.StatusBadRequest, msg) + } + + return nil +} diff --git a/exercise7/blogging-platform/pkg/httputils/response/body.go b/exercise7/blogging-platform/pkg/httputils/response/body.go new file mode 100644 index 00000000..e1fd78a8 --- /dev/null +++ b/exercise7/blogging-platform/pkg/httputils/response/body.go @@ -0,0 +1,33 @@ +package response + +import ( + "encoding/json" + "fmt" + "net/http" +) + +type DataResponse struct { + Data interface{} `json:"data"` +} + +func JSON(w http.ResponseWriter, status int, data interface{}) error { + if data == nil { + w.WriteHeader(http.StatusNoContent) + return nil + } + + js, err := json.Marshal(data) + if err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return fmt.Errorf("JSON marshal error: %w", err) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if _, err := w.Write(js); err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return fmt.Errorf("writer error: %w", err) + } + + return nil +} diff --git a/exercise7/blogging-platform/pkg/httputils/statusError/main.go b/exercise7/blogging-platform/pkg/httputils/statusError/main.go new file mode 100644 index 00000000..6cf4e1b6 --- /dev/null +++ b/exercise7/blogging-platform/pkg/httputils/statusError/main.go @@ -0,0 +1,18 @@ +package statusError + +type StatusError struct { + status int + msg string +} + +func New(status int, msg string) error { + return &StatusError{status, msg} +} + +func (st *StatusError) Error() string { + return st.msg +} + +func (st *StatusError) Status() int { + return st.status +} diff --git a/exercise8/.env-example b/exercise8/.env-example new file mode 100644 index 00000000..d64e862d --- /dev/null +++ b/exercise8/.env-example @@ -0,0 +1,10 @@ +APP_NAME=exercise8 +APP_PORT=8000 + +DB_HOST=127.0.0.1 +DB_NAME=exercise8 +DB_USER=exercise8 +DB_PASSWORD=Qwe123 +DB_PORT=5444 + +TOKEN_SECRET=hghhkjljjo878 \ No newline at end of file diff --git a/exercise8/.gitignore b/exercise8/.gitignore new file mode 100644 index 00000000..bcc49434 --- /dev/null +++ b/exercise8/.gitignore @@ -0,0 +1,8 @@ +.env +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +db-data \ No newline at end of file diff --git a/exercise8/README.md b/exercise8/README.md new file mode 100644 index 00000000..9850e33f --- /dev/null +++ b/exercise8/README.md @@ -0,0 +1,26 @@ +# Movie Reservation + +docker compose run + +```shell +$ docker compose --env-file=./.env up --build +``` + +curl for api request + +```shell +$ curl 'http://127.0.0.1:4013/movies' +``` + +migrations + +```shell +docker compose --profile tools run --rm migrate create -ext sql -dir ./migrations NAME_OF_MIGRATION_FILE +docker compose --profile tools run --rm migrate {up,down} +``` + +seeds + +```shell +go run ./internal/cli/... seed +``` diff --git a/exercise8/docker-compose.yml b/exercise8/docker-compose.yml new file mode 100644 index 00000000..1cff1004 --- /dev/null +++ b/exercise8/docker-compose.yml @@ -0,0 +1,50 @@ +services: +# server: +# build: +# context: . +# target: final +# environment: +# - API_PORT=80 +# - DB_HOST=db +# - DB_PORT=5432 +# - DB_NAME=$DB_NAME +# - DB_USER=$DB_USER +# - DB_PASSWORD=$DB_PASSWORD +# ports: +# - ${API_PORT}:80 +# depends_on: +# db: +# condition: service_healthy + + db: + image: postgres + restart: always + volumes: + - ./db-data:/var/lib/postgresql/data + environment: + - POSTGRES_DB=$DB_NAME + - POSTGRES_USER=$DB_USER + - POSTGRES_PASSWORD=$DB_PASSWORD + ports: + - ${DB_PORT}:5432 + healthcheck: + test: [ "CMD", "pg_isready" ] + interval: 10s + timeout: 5s + retries: 5 + + migrate: + image: migrate/migrate + profiles: [ "tools" ] + entrypoint: [ + "migrate", + "-path", + "./migrations", + "-database", + "postgres://$DB_USER:$DB_PASSWORD@db:5432/$DB_NAME?sslmode=disable" + ] + volumes: + - ./internal/db/migrations:/migrations + depends_on: + db: + condition: service_healthy diff --git a/exercise8/go.mod b/exercise8/go.mod new file mode 100644 index 00000000..5ad08780 --- /dev/null +++ b/exercise8/go.mod @@ -0,0 +1,10 @@ +module expense_tracker + +go 1.23.3 + +require ( + github.com/joho/godotenv v1.5.1 + github.com/lib/pq v1.10.9 +) + +require github.com/golang-jwt/jwt/v5 v5.2.1 // indirect diff --git a/exercise8/go.sum b/exercise8/go.sum new file mode 100644 index 00000000..f0b2cdb0 --- /dev/null +++ b/exercise8/go.sum @@ -0,0 +1,6 @@ +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= diff --git a/exercise8/internal/api/handler/auth/access_token.go b/exercise8/internal/api/handler/auth/access_token.go new file mode 100644 index 00000000..a4e72448 --- /dev/null +++ b/exercise8/internal/api/handler/auth/access_token.go @@ -0,0 +1,115 @@ +package auth + +import ( + "fmt" + "net/http" + "os" + + "expense_tracker/internal/auth" + dbAuth "expense_tracker/internal/db/auth" + "expense_tracker/pkg/httputils/request" + "expense_tracker/pkg/httputils/response" +) + +type AccessTokenRefreshTokenRequest struct { + RefreshToken string `json:"refresh_token"` +} + +type AccessTokenRequest struct { + Data *AccessTokenRefreshTokenRequest `json:"data"` +} + +type AccessTokenResponse struct { + Data *AuthTokenPair `json:"data"` +} + +func (h *Auth) AccessToken(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := h.logger.With("method", "AccessToken") + + // request parse + requestBody := &AccessTokenRequest{} + + if err := request.JSON(w, r, requestBody); err != nil { + log.ErrorContext( + ctx, + "failed to parse request body", + "error", err, + ) + http.Error(w, "failed to parse request body", http.StatusBadRequest) + return + } + + userData, err := auth.ParseToken(requestBody.Data.RefreshToken, os.Getenv("TOKEN_SECRET")) + if err != nil { + log.ErrorContext( + ctx, + "fail authentication", + "error", err, + ) + http.Error( + w, + http.StatusText(http.StatusUnauthorized), + http.StatusUnauthorized, + ) + return + } + + // db request + dbResp, err := h.db.AccessToken(ctx, &dbAuth.AccessTokenInput{UserID: userData.ID}) + if err != nil { + log.ErrorContext( + ctx, + "failed to query from db", + "error", err, + ) + http.Error(w, "failed to query from db", http.StatusInternalServerError) + return + } + if dbResp == nil { + log.ErrorContext( + ctx, + "row is empty", + ) + http.Error(w, "row is empty", http.StatusInternalServerError) + return + } + + tokenPair, err := auth.GenerateTokenPair( + &auth.UserData{ + ID: fmt.Sprint(dbResp.ID), + Email: dbResp.Email, + }, + os.Getenv("TOKEN_SECRET"), + ) + if err != nil { + http.Error(w, fmt.Sprintf("invalid request %w", err), http.StatusBadRequest) + return + } + + // response + respBody := &AccessTokenResponse{ + Data: &AuthTokenPair{ + AccessToken: tokenPair.AccessToken, + RefreshToken: tokenPair.RefreshToken, + }, + } + if err := response.JSON( + w, + http.StatusOK, + respBody, + ); err != nil { + log.ErrorContext( + ctx, + "fail json", + "error", err, + ) + return + } + + log.InfoContext( + ctx, + "success generate access token", + ) + return +} diff --git a/exercise8/internal/api/handler/auth/login.go b/exercise8/internal/api/handler/auth/login.go new file mode 100644 index 00000000..be78f38a --- /dev/null +++ b/exercise8/internal/api/handler/auth/login.go @@ -0,0 +1,124 @@ +package auth + +import ( + "fmt" + "net/http" + "os" + + "expense_tracker/internal/auth" + "expense_tracker/pkg/httputils/request" + "expense_tracker/pkg/httputils/response" +) + +type LoginRequest struct { + Data *AuthUser `json:"data"` +} + +type LoginResponse struct { + Data *AuthTokenPair `json:"data"` +} + +func (h *Auth) Login(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := h.logger.With("method", "Login") + + // request parse + requestBody := &LoginRequest{} + + if err := request.JSON(w, r, requestBody); err != nil { + log.ErrorContext( + ctx, + "failed to parse request body", + "error", err, + ) + http.Error(w, "failed to parse request body", http.StatusBadRequest) + return + } + + // db request + dbResp, err := h.db.Login(ctx, requestBody.Data.Email) + if err != nil { + log.ErrorContext( + ctx, + "failed to query from db", + "error", err, + ) + http.Error(w, "failed to query from db", http.StatusInternalServerError) + return + } + + if dbResp == nil { + log.ErrorContext( + ctx, + "row is empty", + "email", requestBody.Data.Email, + ) + http.Error(w, "invalid credentials", http.StatusBadRequest) + return + } + + // passwordHash and salt + isValid, err := auth.VerifyPassword( + requestBody.Data.Password, + os.Getenv("TOKEN_PEPPER"), + dbResp.PasswordHash, + dbResp.Salt, + ) + if err != nil { + log.ErrorContext( + ctx, + "verify password failed", + "error", err, + ) + http.Error(w, "verify password failed", http.StatusInternalServerError) + return + } + if !isValid { + log.ErrorContext( + ctx, + "invalid credentials", + "email", requestBody.Data.Email, + ) + http.Error(w, "invalid credentials", http.StatusBadRequest) + return + } + + tokenPair, err := auth.GenerateTokenPair( + &auth.UserData{ + ID: fmt.Sprint(dbResp.ID), + Email: dbResp.Email, + }, + os.Getenv("TOKEN_SECRET"), + ) + if err != nil { + http.Error(w, fmt.Sprintf("invalid request %w", err), http.StatusBadRequest) + return + } + + // response + respBody := &LoginResponse{ + Data: &AuthTokenPair{ + AccessToken: tokenPair.AccessToken, + RefreshToken: tokenPair.RefreshToken, + }, + } + if err := response.JSON( + w, + http.StatusOK, + respBody, + ); err != nil { + log.ErrorContext( + ctx, + "fail json", + "error", err, + ) + return + } + + log.InfoContext( + ctx, + "success login user", + "email", dbResp.Email, + ) + return +} diff --git a/exercise8/internal/api/handler/auth/main.go b/exercise8/internal/api/handler/auth/main.go new file mode 100644 index 00000000..b0808190 --- /dev/null +++ b/exercise8/internal/api/handler/auth/main.go @@ -0,0 +1,29 @@ +package auth + +import ( + "log/slog" + + "expense_tracker/internal/db" +) + +type Auth struct { + logger *slog.Logger + db *db.DB +} + +func New(logger *slog.Logger, db *db.DB) *Auth { + return &Auth{ + logger: logger, + db: db, + } +} + +type AuthUser struct { + Email string `json:"email"` + Password string `json:"password"` +} + +type AuthTokenPair struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` +} diff --git a/exercise8/internal/api/handler/auth/register.go b/exercise8/internal/api/handler/auth/register.go new file mode 100644 index 00000000..2696c64f --- /dev/null +++ b/exercise8/internal/api/handler/auth/register.go @@ -0,0 +1,107 @@ +package auth + +import ( + "fmt" + "net/http" + "os" + + "expense_tracker/internal/auth" + dbAuth "expense_tracker/internal/db/auth" + "expense_tracker/pkg/httputils/request" + "expense_tracker/pkg/httputils/response" +) + +type RegisterRequest struct { + Data *AuthUser `json:"data"` +} + +type RegisterResponse struct { + Data *AuthTokenPair `json:"data"` +} + +func (h *Auth) Register(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := h.logger.With("method", "Register") + + // request parse + requestBody := &RegisterRequest{} + + if err := request.JSON(w, r, requestBody); err != nil { + log.ErrorContext( + ctx, + "failed to parse request body", + "error", err, + ) + http.Error(w, "failed to parse request body", http.StatusBadRequest) + return + } + + // passwordHash and salt + passHash, salt, err := auth.HashPassword(requestBody.Data.Password, os.Getenv("TOKEN_PEPPER")) + if err != nil { + log.ErrorContext( + ctx, + "hashing password failed", + "error", err, + ) + http.Error(w, "hashing password failed", http.StatusInternalServerError) + return + } + + // db request + userModel := &dbAuth.ModelUser{ + Email: requestBody.Data.Email, + PasswordHash: passHash, + Salt: salt, + } + dbResp, err := h.db.Register(ctx, &dbAuth.RegisterInput{userModel}) + if err != nil { + log.ErrorContext( + ctx, + "failed to insert user data", + "error", err, + ) + http.Error(w, "failed to insert user data", http.StatusInternalServerError) + return + } + + // create token pair + tokenPair, err := auth.GenerateTokenPair( + &auth.UserData{ + ID: fmt.Sprint(dbResp.ID), + Email: dbResp.Email, + }, + os.Getenv("TOKEN_SECRET"), + ) + + if err != nil { + http.Error(w, fmt.Sprintf("invalid request %w", err), http.StatusBadRequest) + return + } + + respBody := &RegisterResponse{ + Data: &AuthTokenPair{ + AccessToken: tokenPair.AccessToken, + RefreshToken: tokenPair.RefreshToken, + }, + } + if err := response.JSON( + w, + http.StatusOK, + respBody, + ); err != nil { + log.ErrorContext( + ctx, + "fail json", + "error", err, + ) + return + } + + log.InfoContext( + ctx, + "success register user", + "user email", userModel.Email, + ) + return +} diff --git a/exercise8/internal/api/handler/expense/create_expense.go b/exercise8/internal/api/handler/expense/create_expense.go new file mode 100644 index 00000000..afa088a9 --- /dev/null +++ b/exercise8/internal/api/handler/expense/create_expense.go @@ -0,0 +1,50 @@ +package expense + +import ( + "expense_tracker/internal/db/expense" + "expense_tracker/pkg/httputils/request" + "expense_tracker/pkg/httputils/response" + "net/http" +) + +type CreateExpenseResponse struct { + Data *expense.CreateModelExpense `json:"data"` +} + +func (h *Expense) CreateExpense(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + log := h.logger.With("CreateExpense") + + requestBody := &CreateExpenseResponse{} + if err := request.JSON(w, r, requestBody); err != nil { + log.ErrorContext(ctx, "failed to parse request body", "error", err) + http.Error(w, "failed to parse request body", http.StatusBadRequest) + return + } + + dbResp, err := h.db.CreateExpense(ctx, requestBody.Data) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + } + + resp := FindExpenseResponse{ + Data: dbResp, + } + + if err := response.JSON( + w, + http.StatusOK, + resp, + ); err != nil { + log.ErrorContext( + ctx, + "fail json", + "error", err, + ) + return + } + + log.InfoContext(ctx, "success create expense") + return +} diff --git a/exercise8/internal/api/handler/expense/delete_expense.go b/exercise8/internal/api/handler/expense/delete_expense.go new file mode 100644 index 00000000..7fc7b23e --- /dev/null +++ b/exercise8/internal/api/handler/expense/delete_expense.go @@ -0,0 +1,50 @@ +package expense + +import ( + "expense_tracker/internal/db/expense" + "expense_tracker/pkg/httputils/response" + "net/http" + "strconv" +) + +type DeleteExpenseResponse struct { + Data *expense.ModelExpense `json:"data"` +} + +func (h *Expense) DeleteExpense(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + log := h.logger.With("DeleteExpense") + + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + log.ErrorContext( + ctx, + "failed parse query offset", + "error", err, + ) + http.Error(w, "invalid query offset", http.StatusBadRequest) + return + } + + err = h.db.DeleteExpense(ctx, id) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + } + + if err := response.JSON( + w, + http.StatusOK, + nil, + ); err != nil { + log.ErrorContext( + ctx, + "fail json", + "error", err, + ) + return + } + + log.InfoContext(ctx, "success deleted expense") + return +} diff --git a/exercise8/internal/api/handler/expense/find_expense.go b/exercise8/internal/api/handler/expense/find_expense.go new file mode 100644 index 00000000..9bbd443b --- /dev/null +++ b/exercise8/internal/api/handler/expense/find_expense.go @@ -0,0 +1,54 @@ +package expense + +import ( + "expense_tracker/internal/db/expense" + "expense_tracker/pkg/httputils/response" + "net/http" + "strconv" +) + +type FindExpenseResponse struct { + Data *expense.ModelExpense `json:"data"` +} + +func (h *Expense) FindExpense(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + log := h.logger.With("FindExpense") + + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + log.ErrorContext( + ctx, + "failed parse query offset", + "error", err, + ) + http.Error(w, "invalid query offset", http.StatusBadRequest) + return + } + + dbData, err := h.db.FindExpense(ctx, id) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + } + + resp := FindExpenseResponse{ + Data: dbData, + } + + if err := response.JSON( + w, + http.StatusOK, + resp, + ); err != nil { + log.ErrorContext( + ctx, + "fail json", + "error", err, + ) + return + } + + log.InfoContext(ctx, "success find expense", "successfully get expense") + return +} diff --git a/exercise8/internal/api/handler/expense/find_expenses.go b/exercise8/internal/api/handler/expense/find_expenses.go new file mode 100644 index 00000000..a60dae47 --- /dev/null +++ b/exercise8/internal/api/handler/expense/find_expenses.go @@ -0,0 +1,72 @@ +package expense + +import ( + "expense_tracker/internal/db/expense" + "expense_tracker/pkg/httputils/response" + "net/http" + "strconv" + "time" +) + +type FindExpensesResponse struct { + Data []expense.ModelExpense `json:"data"` +} + +func (h *Expense) FindExpenses(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + log := h.logger.With("FindExpenses") + + query := r.URL.Query() + offset, err := strconv.Atoi(query.Get("offset")) + if err != nil { + log.ErrorContext( + ctx, + "failed parse query offset", + "error", err, + ) + http.Error(w, "invalid query offset", http.StatusBadRequest) + return + } + + limit, err := strconv.Atoi(query.Get("limit")) + if err != nil { + log.ErrorContext( + ctx, + "failed parse query limit", + "error", err, + ) + http.Error(w, "invalid query limit", http.StatusBadRequest) + return + } + + filter := query.Get("filter") + if filter == "" { + filter = time.Time{}.String() + } + + dbData, err := h.db.FindExpenses(ctx, limit, offset, filter) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + } + + resp := FindExpensesResponse{ + Data: dbData, + } + + if err := response.JSON( + w, + http.StatusOK, + resp, + ); err != nil { + log.ErrorContext( + ctx, + "fail json", + "error", err, + ) + return + } + + log.InfoContext(ctx, "success find expenses", "number of expenses", len(resp.Data)) + return +} diff --git a/exercise8/internal/api/handler/expense/main.go b/exercise8/internal/api/handler/expense/main.go new file mode 100644 index 00000000..db615f92 --- /dev/null +++ b/exercise8/internal/api/handler/expense/main.go @@ -0,0 +1,18 @@ +package expense + +import ( + "expense_tracker/internal/db" + "log/slog" +) + +type Expense struct { + logger *slog.Logger + db *db.DB +} + +func New(logger *slog.Logger, db *db.DB) *Expense { + return &Expense{ + logger: logger, + db: db, + } +} diff --git a/exercise8/internal/api/handler/expense/update_expense.go b/exercise8/internal/api/handler/expense/update_expense.go new file mode 100644 index 00000000..7b1437da --- /dev/null +++ b/exercise8/internal/api/handler/expense/update_expense.go @@ -0,0 +1,58 @@ +package expense + +import ( + "expense_tracker/internal/db/expense" + "expense_tracker/pkg/httputils/request" + "expense_tracker/pkg/httputils/response" + "net/http" + "strconv" +) + +type UpdateExpenseResponse struct { + Data *expense.ModelExpense `json:"data"` +} + +func (h *Expense) UpdateExpense(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + log := h.logger.With("UpdateExpense") + + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + log.ErrorContext( + ctx, + "failed parse query offset", + "error", err, + ) + http.Error(w, "invalid query offset", http.StatusBadRequest) + return + } + + requestBody := &UpdateExpenseResponse{} + if err := request.JSON(w, r, requestBody); err != nil { + log.ErrorContext(ctx, "failed to parse request body", "error", err) + http.Error(w, "failed to parse request body", http.StatusBadRequest) + return + } + + err = h.db.UpdateExpense(ctx, requestBody.Data, id) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + } + + if err := response.JSON( + w, + http.StatusOK, + nil, + ); err != nil { + log.ErrorContext( + ctx, + "fail json", + "error", err, + ) + return + } + + log.InfoContext(ctx, "success create expense") + return +} diff --git a/exercise8/internal/api/handler/main.go b/exercise8/internal/api/handler/main.go new file mode 100644 index 00000000..dd1c0d8d --- /dev/null +++ b/exercise8/internal/api/handler/main.go @@ -0,0 +1,20 @@ +package handler + +import ( + "expense_tracker/internal/api/handler/auth" + "expense_tracker/internal/api/handler/expense" + "expense_tracker/internal/db" + "log/slog" +) + +type Handler struct { + *expense.Expense + *auth.Auth +} + +func New(logger *slog.Logger, db *db.DB) *Handler { + return &Handler{ + Expense: expense.New(logger, db), + Auth: auth.New(logger, db), + } +} diff --git a/exercise8/internal/api/main.go b/exercise8/internal/api/main.go new file mode 100644 index 00000000..b6ae62d9 --- /dev/null +++ b/exercise8/internal/api/main.go @@ -0,0 +1,54 @@ +package api + +import ( + "context" + "errors" + "expense_tracker/internal/api/handler" + "expense_tracker/internal/api/middleware" + "expense_tracker/internal/api/router" + "expense_tracker/internal/db" + "fmt" + "log/slog" + "net" + "net/http" + "os" + "strconv" +) + +type Api struct { + logger *slog.Logger + router *router.Router +} + +func New(logger *slog.Logger, db *db.DB) *Api { + midd := middleware.New(logger) + h := handler.New(logger, db) + r := router.New(logger, h, midd) + + return &Api{ + logger: logger, + router: r, + } +} + +func (a *Api) Start(ctx context.Context) error { + mux := a.router.Start(ctx) + + port, err := strconv.Atoi(os.Getenv("APP_PORT")) + if err != nil { + return err + } + + srv := &http.Server{ + Addr: fmt.Sprintf(":%d", port), + Handler: mux, + BaseContext: func(_ net.Listener) context.Context { return ctx }, + } + + fmt.Printf("Starting server on :%d\n", port) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + + return nil +} diff --git a/exercise8/internal/api/middleware/authenticator.go b/exercise8/internal/api/middleware/authenticator.go new file mode 100644 index 00000000..ac0f6e88 --- /dev/null +++ b/exercise8/internal/api/middleware/authenticator.go @@ -0,0 +1,63 @@ +package middleware + +import ( + "context" + "expense_tracker/internal/auth" + "net/http" + "os" + "strings" +) + +func (m *Middleware) Authenticator(next http.Handler) http.Handler { + h := func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := m.logger.With("middleware", "Authorization") + + authenticatorHeader := r.Header.Get("Authorization") + if authenticatorHeader == "" { + log.ErrorContext( + ctx, + "authenticator header is empty", + ) + http.Error( + w, + http.StatusText(http.StatusUnauthorized), + http.StatusUnauthorized, + ) + return + } + + if !strings.HasPrefix(authenticatorHeader, "Bearer ") { + log.ErrorContext( + ctx, + "invalid authenticator header", + ) + http.Error( + w, + http.StatusText(http.StatusUnauthorized), + http.StatusUnauthorized, + ) + return + } + + tokenString := authenticatorHeader[len("Bearer "):] + userData, err := auth.ParseToken(tokenString, os.Getenv("TOKEN_SECRET")) + if err != nil { + log.ErrorContext( + ctx, + "fail authentication", + "error", err, + ) + http.Error( + w, + http.StatusText(http.StatusUnauthorized), + http.StatusUnauthorized, + ) + return + } + + newCtx := context.WithValue(ctx, "user", userData) + next.ServeHTTP(w, r.WithContext(newCtx)) + } + return http.HandlerFunc(h) +} diff --git a/exercise8/internal/api/middleware/main.go b/exercise8/internal/api/middleware/main.go new file mode 100644 index 00000000..75cc9648 --- /dev/null +++ b/exercise8/internal/api/middleware/main.go @@ -0,0 +1,15 @@ +package middleware + +import ( + "log/slog" +) + +type Middleware struct { + logger *slog.Logger +} + +func New(logger *slog.Logger) *Middleware { + return &Middleware{ + logger: logger, + } +} diff --git a/exercise8/internal/api/router/auth.go b/exercise8/internal/api/router/auth.go new file mode 100644 index 00000000..a861e07b --- /dev/null +++ b/exercise8/internal/api/router/auth.go @@ -0,0 +1,12 @@ +package router + +import ( + "context" +) + +func (r *Router) auth(ctx context.Context) { + r.router.HandleFunc("POST /register", r.handler.Register) + r.router.HandleFunc("POST /login", r.handler.Login) + + r.router.HandleFunc("POST /access-token", r.handler.AccessToken) +} diff --git a/exercise8/internal/api/router/expense.go b/exercise8/internal/api/router/expense.go new file mode 100644 index 00000000..6a76c76f --- /dev/null +++ b/exercise8/internal/api/router/expense.go @@ -0,0 +1,15 @@ +package router + +import ( + "context" + "net/http" +) + +func (r *Router) expense(ctx context.Context) { + r.router.Handle("GET /expenses", r.mid.Authenticator(http.HandlerFunc(r.handler.FindExpenses))) + r.router.Handle("GET /expense/{id}", r.mid.Authenticator(http.HandlerFunc(r.handler.FindExpense))) + r.router.Handle("POST /expense", r.mid.Authenticator(http.HandlerFunc(r.handler.CreateExpense))) + r.router.Handle("PUT /expense/{id}", r.mid.Authenticator(http.HandlerFunc(r.handler.UpdateExpense))) + r.router.Handle("DELETE /expense/{id}", r.mid.Authenticator(http.HandlerFunc(r.handler.DeleteExpense))) + +} diff --git a/exercise8/internal/api/router/main.go b/exercise8/internal/api/router/main.go new file mode 100644 index 00000000..8fafc2b7 --- /dev/null +++ b/exercise8/internal/api/router/main.go @@ -0,0 +1,33 @@ +package router + +import ( + "context" + "expense_tracker/internal/api/handler" + "expense_tracker/internal/api/middleware" + "log/slog" + "net/http" +) + +type Router struct { + logger *slog.Logger + router *http.ServeMux + handler *handler.Handler + mid *middleware.Middleware +} + +func New(logger *slog.Logger, handler *handler.Handler, mid *middleware.Middleware) *Router { + mux := http.NewServeMux() + + return &Router{ + logger: logger, + router: mux, + handler: handler, + mid: mid, + } +} + +func (r *Router) Start(ctx context.Context) *http.ServeMux { + r.expense(ctx) + r.auth(ctx) + return r.router +} diff --git a/exercise8/internal/auth/hash.go b/exercise8/internal/auth/hash.go new file mode 100644 index 00000000..016883aa --- /dev/null +++ b/exercise8/internal/auth/hash.go @@ -0,0 +1,71 @@ +package auth + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha512" + "crypto/subtle" + "encoding/base64" + "fmt" +) + +const ( + // Recommended minimum lengths for security + SaltLength = 16 // 16 bytes = 128 bits + PepperLength = 32 // 32 bytes = 256 bits + HashLength = 64 // SHA-512 outputs 64 bytes + Iterations = 210000 // Recommended minimum iterations for PBKDF2 +) + +func HashPassword(password string, pepper string) (string, string, error) { + // Generate random salt + salt := make([]byte, SaltLength) + if _, err := rand.Read(salt); err != nil { + return "", "", fmt.Errorf("error generating salt: %w", err) + } + + // Hash the password + hash := hashWithSaltAndPepper([]byte(password), salt, []byte(pepper)) + + return base64.StdEncoding.EncodeToString(hash), base64.StdEncoding.EncodeToString(salt), nil +} + +func hashWithSaltAndPepper(password, salt, pepper []byte) []byte { + // Combine password and pepper + pepperedPassword := make([]byte, len(password)+len(pepper)) + copy(pepperedPassword, password) + copy(pepperedPassword[len(password):], pepper) + + // Initialize HMAC-SHA512 + hash := hmac.New(sha512.New, salt) + + // Perform multiple iterations of hashing + result := pepperedPassword + for i := 0; i < Iterations; i++ { + hash.Reset() + hash.Write(result) + result = hash.Sum(nil) + } + + return result +} + +// VerifyPassword checks if a password matches its hash +func VerifyPassword(password, pepper, hash, salt string) (bool, error) { + // Decode salt and hash from base64 + decodedSalt, err := base64.StdEncoding.DecodeString(salt) + if err != nil { + return false, fmt.Errorf("error decoding salt: %w", err) + } + + decodedHash, err := base64.StdEncoding.DecodeString(hash) + if err != nil { + return false, fmt.Errorf("error decoding hash: %w", err) + } + + // Hash the provided password with the same salt + newHash := hashWithSaltAndPepper([]byte(password), decodedSalt, []byte(pepper)) + + // Compare hashes in constant time to prevent timing attacks + return subtle.ConstantTimeCompare(newHash, decodedHash) == 1, nil +} diff --git a/exercise8/internal/auth/main.go b/exercise8/internal/auth/main.go new file mode 100644 index 00000000..3cf5f8ba --- /dev/null +++ b/exercise8/internal/auth/main.go @@ -0,0 +1,11 @@ +package auth + +type Tokens struct { + RefreshToken string `json:"refresh_token"` + AccessToken string `json:"access_token"` +} + +type UserData struct { + ID string `json:"id"` + Email string `json:"email"` +} diff --git a/exercise8/internal/auth/tokens.go b/exercise8/internal/auth/tokens.go new file mode 100644 index 00000000..a28ba08b --- /dev/null +++ b/exercise8/internal/auth/tokens.go @@ -0,0 +1,83 @@ +package auth + +import ( + "errors" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +func GenerateTokenPair(user *UserData, secret string) (*Tokens, error) { + // Create token + token := jwt.New(jwt.SigningMethodHS256) + + // Set claims + // This is the information which frontend can use + // The backend can also decode the token and get admin etc. + claims := token.Claims.(jwt.MapClaims) + claims["sub"] = user.ID + claims["email"] = user.Email + claims["exp"] = time.Now().Add(time.Minute * 600).Unix() + + // Generate encoded token and send it as response. + // The signing string should be secret (a generated UUID works too) + t, err := token.SignedString([]byte(secret)) + if err != nil { + return nil, err + } + + refreshToken := jwt.New(jwt.SigningMethodHS256) + rtClaims := refreshToken.Claims.(jwt.MapClaims) + rtClaims["sub"] = user.ID + rtClaims["exp"] = time.Now().Add(time.Hour * 24).Unix() + + rt, err := refreshToken.SignedString([]byte(secret)) + if err != nil { + return nil, err + } + + return &Tokens{ + AccessToken: t, + RefreshToken: rt, + }, nil +} + +func ParseToken(token string, secret string) (*UserData, error) { + t, err := jwt.Parse( + token, func(token *jwt.Token) (interface{}, error) { + return []byte(secret), nil + }, + ) + + switch { + case t == nil: + return nil, fmt.Errorf("invalid token") + case t.Valid: + claims, ok := t.Claims.(jwt.MapClaims) + if !ok { + return nil, fmt.Errorf("invalid token") + } + + id, err := claims.GetSubject() + if err != nil { + return nil, fmt.Errorf("invalid token") + } + email, ok := claims["email"].(string) + + return &UserData{ + ID: id, + Email: email, + }, nil + case errors.Is(err, jwt.ErrTokenMalformed): + return nil, fmt.Errorf("invalid token") + case errors.Is(err, jwt.ErrTokenSignatureInvalid): + // Invalid signature + return nil, fmt.Errorf("Invalid signature") + case errors.Is(err, jwt.ErrTokenExpired) || errors.Is(err, jwt.ErrTokenNotValidYet): + // Token is either expired or not active yet + return nil, err + default: + return nil, fmt.Errorf("invalid token") + } +} diff --git a/exercise8/internal/db/auth/access_token.go b/exercise8/internal/db/auth/access_token.go new file mode 100644 index 00000000..b3862feb --- /dev/null +++ b/exercise8/internal/db/auth/access_token.go @@ -0,0 +1,45 @@ +package auth + +import ( + "context" + "database/sql" + "errors" +) + +type AccessTokenInput struct { + UserID string +} + +func (m *Auth) AccessToken(ctx context.Context, inp *AccessTokenInput) (*ModelUser, error) { + log := m.logger.With("method", "AccessToken") + + stmt := ` +SELECT id, email +FROM users +WHERE id = $1; +` + + row := m.db.QueryRowContext(ctx, stmt, inp.UserID) + if err := row.Err(); err != nil { + log.ErrorContext(ctx, "fail to query table user", "error", err) + return nil, err + } + + user := ModelUser{} + + if err := row.Scan( + &user.ID, + &user.Email, + ); err != nil { + if errors.Is(err, sql.ErrNoRows) { + log.ErrorContext(ctx, "no user found", "error", err) + return nil, nil + } + + log.ErrorContext(ctx, "fail to scan user", "error", err) + return nil, err + } + + log.InfoContext(ctx, "success select user") + return &user, nil +} diff --git a/exercise8/internal/db/auth/login.go b/exercise8/internal/db/auth/login.go new file mode 100644 index 00000000..a9a920d9 --- /dev/null +++ b/exercise8/internal/db/auth/login.go @@ -0,0 +1,48 @@ +package auth + +import ( + "context" + "database/sql" + "errors" +) + +type LoginInput struct { + User *ModelUser +} + +func (m *Auth) Login(ctx context.Context, email string) (*ModelUser, error) { + log := m.logger.With("method", "Login") + + stmt := ` +SELECT id, email, password_hash, salt +FROM users +WHERE email = $1; +` + + row := m.db.QueryRowContext(ctx, stmt, email) + + if err := row.Err(); err != nil { + log.ErrorContext(ctx, "fail to query table user", "error", err) + return nil, err + } + + user := ModelUser{} + + if err := row.Scan( + &user.ID, + &user.Email, + &user.PasswordHash, + &user.Salt, + ); err != nil { + if errors.Is(err, sql.ErrNoRows) { + log.WarnContext(ctx, "no user found", "error", err) + return nil, nil + } + + log.ErrorContext(ctx, "fail to scan user", "error", err) + return nil, err + } + + log.InfoContext(ctx, "success login user") + return &user, nil +} diff --git a/exercise8/internal/db/auth/main.go b/exercise8/internal/db/auth/main.go new file mode 100644 index 00000000..d92f78f0 --- /dev/null +++ b/exercise8/internal/db/auth/main.go @@ -0,0 +1,28 @@ +package auth + +import ( + "database/sql" + "log/slog" +) + +type Auth struct { + logger *slog.Logger + db *sql.DB +} + +func New(logger *slog.Logger, db *sql.DB) *Auth { + return &Auth{ + logger: logger, + db: db, + } +} + +type AuthUser struct { + Email string `json:"email"` + Password string `json:"password"` +} + +type AuthTokenPair struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` +} diff --git a/exercise8/internal/db/auth/model.go b/exercise8/internal/db/auth/model.go new file mode 100644 index 00000000..3d9b81f9 --- /dev/null +++ b/exercise8/internal/db/auth/model.go @@ -0,0 +1,14 @@ +package auth + +import ( + "time" +) + +type ModelUser struct { + ID int `json:"id"` + Email string `json:"email"` + PasswordHash string `json:"-"` + Salt string `json:"-"` + CreatedAt *time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at"` +} diff --git a/exercise8/internal/db/auth/register.go b/exercise8/internal/db/auth/register.go new file mode 100644 index 00000000..d4c9c725 --- /dev/null +++ b/exercise8/internal/db/auth/register.go @@ -0,0 +1,40 @@ +package auth + +import ( + "context" +) + +type RegisterInput struct { + User *ModelUser +} + +func (m *Auth) Register(ctx context.Context, inp *RegisterInput) (*ModelUser, error) { + log := m.logger.With("method", "Register") + + stmt := ` +INSERT INTO users (email, password_hash, salt) +VALUES ($1, $2, $3) +RETURNING id, email, password_hash, salt; +` + + row := m.db.QueryRowContext(ctx, stmt, inp.User.Email, inp.User.PasswordHash, inp.User.Salt) + if err := row.Err(); err != nil { + log.ErrorContext(ctx, "fail to query table user", "error", err) + return nil, err + } + + user := ModelUser{} + + if err := row.Scan( + &user.ID, + &user.Email, + &user.PasswordHash, + &user.Salt, + ); err != nil { + log.ErrorContext(ctx, "fail to scan user", "error", err) + return nil, err + } + + log.InfoContext(ctx, "success register user") + return &user, nil +} diff --git a/exercise8/internal/db/expense/create_expense.go b/exercise8/internal/db/expense/create_expense.go new file mode 100644 index 00000000..00467a35 --- /dev/null +++ b/exercise8/internal/db/expense/create_expense.go @@ -0,0 +1,38 @@ +package expense + +import ( + "context" +) + +func (d *Expense) CreateExpense(ctx context.Context, expenseData *CreateModelExpense) (*ModelExpense, error) { + log := d.logger.With("method", "CreateExpenses") + + stmt := `INSERT INTO expenses (amount, description) + VALUES ($1, $2) + RETURNING id, amount, description, created_at, updated_at` + + row := d.db.QueryRowContext(ctx, stmt, expenseData.Amount, expenseData.Description) + if err := row.Err(); err != nil { + log.ErrorContext(ctx, "fail to query table expenses", "error", err) + return nil, err + } + + expense := ModelExpense{} + if err := row.Scan( + &expense.ID, + &expense.Amount, + &expense.Description, + &expense.CreatedAt, + &expense.UpdatedAt, + ); err != nil { + log.ErrorContext(ctx, "fail to scan expense", "error", err) + return nil, err + } + + if err := row.Err(); err != nil { + log.ErrorContext(ctx, "fail to scan rows", "error", err) + return nil, err + } + + return &expense, nil +} diff --git a/exercise8/internal/db/expense/delete_expense.go b/exercise8/internal/db/expense/delete_expense.go new file mode 100644 index 00000000..c77f3a4d --- /dev/null +++ b/exercise8/internal/db/expense/delete_expense.go @@ -0,0 +1,19 @@ +package expense + +import ( + "context" +) + +func (d *Expense) DeleteExpense(ctx context.Context, id int) error { + log := d.logger.With("method", "DeleteExpense") + + stmt := `DELETE FROM expenses WHERE id = $1` + + row := d.db.QueryRowContext(ctx, stmt, id) + if err := row.Err(); err != nil { + log.ErrorContext(ctx, "fail to query table expenses", "error", err) + return err + } + + return nil +} diff --git a/exercise8/internal/db/expense/find_expense.go b/exercise8/internal/db/expense/find_expense.go new file mode 100644 index 00000000..1da7d7c4 --- /dev/null +++ b/exercise8/internal/db/expense/find_expense.go @@ -0,0 +1,36 @@ +package expense + +import ( + "context" +) + +func (d *Expense) FindExpense(ctx context.Context, id int) (*ModelExpense, error) { + log := d.logger.With("method", "FindExpenses") + + stmt := `SELECT * FROM expenses WHERE id = $1` + + row := d.db.QueryRowContext(ctx, stmt, id) + if err := row.Err(); err != nil { + log.ErrorContext(ctx, "fail to query table expenses", "error", err) + return nil, err + } + + expense := ModelExpense{} + if err := row.Scan( + &expense.ID, + &expense.Amount, + &expense.Description, + &expense.CreatedAt, + &expense.UpdatedAt, + ); err != nil { + log.ErrorContext(ctx, "fail to scan expense", "error", err) + return nil, err + } + + if err := row.Err(); err != nil { + log.ErrorContext(ctx, "fail to scan rows", "error", err) + return nil, err + } + + return &expense, nil +} diff --git a/exercise8/internal/db/expense/find_expenses.go b/exercise8/internal/db/expense/find_expenses.go new file mode 100644 index 00000000..33979b79 --- /dev/null +++ b/exercise8/internal/db/expense/find_expenses.go @@ -0,0 +1,62 @@ +package expense + +import ( + "context" + "time" +) + +func (d *Expense) FindExpenses(ctx context.Context, limit, offset int, filter string) ([]ModelExpense, error) { + log := d.logger.With("method", "FindExpenses") + + expenses := make([]ModelExpense, 0) + + stmt := `SELECT * FROM expenses ` + var filterTime time.Time + if filter != "" { + stmt += `WHERE created_at > $3 ` + now := time.Now() + switch filter { + case "past_week": + filterTime = now.AddDate(0, 0, -7) + break + case "past_month": + filterTime = now.AddDate(0, -1, 0) + break + case "last_3_months": + filterTime = now.AddDate(0, -3, 0) + break + } + } + stmt += `ORDER BY id DESC OFFSET $1 LIMIT $2` + + rows, err := d.db.QueryContext(ctx, stmt, offset, limit, filterTime) + if err != nil { + log.ErrorContext(ctx, "fail to query table expenses", "error", err) + return nil, err + } + + defer rows.Close() + + for rows.Next() { + expense := ModelExpense{} + + if err := rows.Scan( + &expense.ID, + &expense.Amount, + &expense.Description, + &expense.CreatedAt, + &expense.UpdatedAt, + ); err != nil { + log.ErrorContext(ctx, "fail to scan expense", "error", err) + return nil, err + } + expenses = append(expenses, expense) + } + + if err := rows.Err(); err != nil { + log.ErrorContext(ctx, "fail to scan rows", "error", err) + return nil, err + } + + return expenses, nil +} diff --git a/exercise8/internal/db/expense/main.go b/exercise8/internal/db/expense/main.go new file mode 100644 index 00000000..05e5761e --- /dev/null +++ b/exercise8/internal/db/expense/main.go @@ -0,0 +1,18 @@ +package expense + +import ( + "database/sql" + "log/slog" +) + +type Expense struct { + logger *slog.Logger + db *sql.DB +} + +func New(logger *slog.Logger, db *sql.DB) *Expense { + return &Expense{ + logger: logger, + db: db, + } +} diff --git a/exercise8/internal/db/expense/model.go b/exercise8/internal/db/expense/model.go new file mode 100644 index 00000000..efba0114 --- /dev/null +++ b/exercise8/internal/db/expense/model.go @@ -0,0 +1,14 @@ +package expense + +type ModelExpense struct { + ID int `json:"id"` + Amount float64 `json:"amount"` + Description string `json:"description"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type CreateModelExpense struct { + Amount float64 `json:"amount"` + Description string `json:"description"` +} diff --git a/exercise8/internal/db/expense/update_expense.go b/exercise8/internal/db/expense/update_expense.go new file mode 100644 index 00000000..2a790dbd --- /dev/null +++ b/exercise8/internal/db/expense/update_expense.go @@ -0,0 +1,21 @@ +package expense + +import ( + "context" +) + +func (d *Expense) UpdateExpense(ctx context.Context, expenseData *ModelExpense, id int) error { + log := d.logger.With("method", "UpdateExpense") + + stmt := `UPDATE expenses + SET amount = $2, description = $3 + WHERE id = $1` + + row := d.db.QueryRowContext(ctx, stmt, id, expenseData.Amount, expenseData.Description) + if err := row.Err(); err != nil { + log.ErrorContext(ctx, "fail to query table expenses", "error", err) + return err + } + + return nil +} diff --git a/exercise8/internal/db/main.go b/exercise8/internal/db/main.go new file mode 100644 index 00000000..d4244c0b --- /dev/null +++ b/exercise8/internal/db/main.go @@ -0,0 +1,56 @@ +package db + +import ( + "database/sql" + "expense_tracker/internal/db/auth" + "expense_tracker/internal/db/expense" + "fmt" + _ "github.com/lib/pq" + "log/slog" + "os" + "strconv" +) + +type DB struct { + logger *slog.Logger + pg *sql.DB + *auth.Auth + *expense.Expense +} + +func New(logger *slog.Logger) (*DB, error) { + pgSql, err := NewPgSQL() + if err != nil { + return nil, err + } + + return &DB{ + logger: logger, + pg: pgSql, + Auth: auth.New(logger, pgSql), + Expense: expense.New(logger, pgSql), + }, nil +} + +func NewPgSQL() (*sql.DB, error) { + host := os.Getenv("DB_HOST") + port, err := strconv.Atoi(os.Getenv("DB_PORT")) + if err != nil { + return nil, err + } + user := os.Getenv("DB_USER") + password := os.Getenv("DB_PASSWORD") + dbname := os.Getenv("DB_NAME") + + pgInfo := fmt.Sprintf( + "host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", + host, port, user, password, dbname, + ) + + db, err := sql.Open("postgres", pgInfo) + if err != nil { + return nil, err + } + + return db, nil +} diff --git a/exercise8/internal/db/migrations/20250112104121_create_table_users.down.sql b/exercise8/internal/db/migrations/20250112104121_create_table_users.down.sql new file mode 100755 index 00000000..e69de29b diff --git a/exercise8/internal/db/migrations/20250112104121_create_table_users.up.sql b/exercise8/internal/db/migrations/20250112104121_create_table_users.up.sql new file mode 100755 index 00000000..97183958 --- /dev/null +++ b/exercise8/internal/db/migrations/20250112104121_create_table_users.up.sql @@ -0,0 +1,8 @@ +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + salt TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); \ No newline at end of file diff --git a/exercise8/internal/db/migrations/20250112133656_create_table_expenses.down.sql b/exercise8/internal/db/migrations/20250112133656_create_table_expenses.down.sql new file mode 100755 index 00000000..b6b6cc55 --- /dev/null +++ b/exercise8/internal/db/migrations/20250112133656_create_table_expenses.down.sql @@ -0,0 +1 @@ +DROP TABLE expenses; \ No newline at end of file diff --git a/exercise8/internal/db/migrations/20250112133656_create_table_expenses.up.sql b/exercise8/internal/db/migrations/20250112133656_create_table_expenses.up.sql new file mode 100755 index 00000000..e4f7bbe8 --- /dev/null +++ b/exercise8/internal/db/migrations/20250112133656_create_table_expenses.up.sql @@ -0,0 +1,7 @@ +CREATE TABLE expenses ( + id SERIAL PRIMARY KEY, + amount DECIMAL(10, 2) NOT NULL, + description TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); \ No newline at end of file diff --git a/exercise8/main.go b/exercise8/main.go new file mode 100644 index 00000000..4c87c14d --- /dev/null +++ b/exercise8/main.go @@ -0,0 +1,42 @@ +package main + +import ( + "context" + "expense_tracker/internal/api" + "expense_tracker/internal/db" + "github.com/joho/godotenv" + "log/slog" + "os" + "os/signal" +) + +func main() { + ctx, cancel := context.WithCancel(context.Background()) + _ = godotenv.Load() + + d, err := db.New(slog.With("service", "db")) + if err != nil { + panic(err) + } + + a := api.New(slog.With("service", "api"), d) + + go func(ctx context.Context, cancelFunc context.CancelFunc) { + if err := a.Start(ctx); err != nil { + slog.ErrorContext(ctx, "failed to start api", "error", err.Error()) + } + cancelFunc() + }(ctx, cancel) + + go func(cancelFunc context.CancelFunc) { + shutdown := make(chan os.Signal, 1) + signal.Notify(shutdown, os.Interrupt) + + sig := <-shutdown + slog.WarnContext(ctx, "signal received - shutting down...", "signal", sig) + + cancelFunc() + }(cancel) + + <-ctx.Done() +} diff --git a/exercise8/pkg/httputils/request/body.go b/exercise8/pkg/httputils/request/body.go new file mode 100644 index 00000000..2c829e60 --- /dev/null +++ b/exercise8/pkg/httputils/request/body.go @@ -0,0 +1,76 @@ +package request + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + + "expense_tracker/pkg/httputils/statusError" +) + +func JSON(w http.ResponseWriter, r *http.Request, dst interface{}) error { + ct := r.Header.Get("Content-Type") + if ct != "" { + mediaType := strings.ToLower(strings.TrimSpace(strings.Split(ct, ";")[0])) + if mediaType != "application/json" { + msg := "Content-Type header is not application/json" + return statusError.New(http.StatusUnsupportedMediaType, msg) + } + } + + r.Body = http.MaxBytesReader(w, r.Body, 1048576) + + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + + err := dec.Decode(&dst) + if err != nil { + var syntaxError *json.SyntaxError + var unmarshalTypeError *json.UnmarshalTypeError + + switch { + case errors.As(err, &syntaxError): + msg := fmt.Sprintf("Request body contains badly-formed JSON (at position %d)", syntaxError.Offset) + return statusError.New(http.StatusBadRequest, msg) + + case errors.Is(err, io.ErrUnexpectedEOF): + msg := "Request body contains badly-formed JSON" + return statusError.New(http.StatusBadRequest, msg) + + case errors.As(err, &unmarshalTypeError): + msg := fmt.Sprintf( + "Request body contains an invalid value for the %q field (at position %d)", + unmarshalTypeError.Field, + unmarshalTypeError.Offset, + ) + return statusError.New(http.StatusBadRequest, msg) + + case strings.HasPrefix(err.Error(), "json: unknown field "): + fieldName := strings.TrimPrefix(err.Error(), "json: unknown field ") + msg := fmt.Sprintf("Request body contains unknown field %s", fieldName) + return statusError.New(http.StatusBadRequest, msg) + + case errors.Is(err, io.EOF): + msg := "Request body must not be empty" + return statusError.New(http.StatusBadRequest, msg) + + case err.Error() == "http: request body too large": + msg := "Request body must not be larger than 1MB" + return statusError.New(http.StatusRequestEntityTooLarge, msg) + + default: + return err + } + } + + err = dec.Decode(&struct{}{}) + if !errors.Is(err, io.EOF) { + msg := "Request body must only contain a single JSON object" + return statusError.New(http.StatusBadRequest, msg) + } + + return nil +} diff --git a/exercise8/pkg/httputils/response/body.go b/exercise8/pkg/httputils/response/body.go new file mode 100644 index 00000000..e1fd78a8 --- /dev/null +++ b/exercise8/pkg/httputils/response/body.go @@ -0,0 +1,33 @@ +package response + +import ( + "encoding/json" + "fmt" + "net/http" +) + +type DataResponse struct { + Data interface{} `json:"data"` +} + +func JSON(w http.ResponseWriter, status int, data interface{}) error { + if data == nil { + w.WriteHeader(http.StatusNoContent) + return nil + } + + js, err := json.Marshal(data) + if err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return fmt.Errorf("JSON marshal error: %w", err) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if _, err := w.Write(js); err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return fmt.Errorf("writer error: %w", err) + } + + return nil +} diff --git a/exercise8/pkg/httputils/statusError/main.go b/exercise8/pkg/httputils/statusError/main.go new file mode 100644 index 00000000..6cf4e1b6 --- /dev/null +++ b/exercise8/pkg/httputils/statusError/main.go @@ -0,0 +1,18 @@ +package statusError + +type StatusError struct { + status int + msg string +} + +func New(status int, msg string) error { + return &StatusError{status, msg} +} + +func (st *StatusError) Error() string { + return st.msg +} + +func (st *StatusError) Status() int { + return st.status +} diff --git a/main.go b/main.go index 06ab7d0f..4a9902ab 100644 --- a/main.go +++ b/main.go @@ -1 +1,5 @@ package main + +func main() { + // +}