diff --git a/exercise1/problem1/main.go b/exercise1/problem1/main.go index dfca465c..de0c5077 100644 --- a/exercise1/problem1/main.go +++ b/exercise1/problem1/main.go @@ -1,3 +1,9 @@ package main -func addUp() {} +func addUp(num int) int { + res := 0 + for i := 1; i <= num; i++ { + res = res + i + } + return res +} diff --git a/exercise1/problem10/main.go b/exercise1/problem10/main.go index 04ec3430..f64cd2f1 100644 --- a/exercise1/problem10/main.go +++ b/exercise1/problem10/main.go @@ -1,3 +1,18 @@ package main -func sum() {} +import "strconv" + +func sum(a, b string) (string, error) { + + n1, err := strconv.Atoi(a) + if err != nil { + return "", err + } + n2, err := strconv.Atoi(b) + if err != nil { + return "", err + } + res:=n1+n2 + return strconv.Itoa(res),nil +} + diff --git a/exercise1/problem2/main.go b/exercise1/problem2/main.go index 2ca540b8..49b5823c 100644 --- a/exercise1/problem2/main.go +++ b/exercise1/problem2/main.go @@ -1,3 +1,19 @@ package main -func binary() {} +import "fmt" + +func binary(num int) string { + if num == 0 { + return "0" + } + var binaryStr string + for num > 0 { + remainder := num % 2 + + binaryStr = fmt.Sprintf("%d", remainder) + binaryStr + + num /= 2 + } + + return binaryStr +} diff --git a/exercise1/problem3/main.go b/exercise1/problem3/main.go index d346641a..d8bd7fbb 100644 --- a/exercise1/problem3/main.go +++ b/exercise1/problem3/main.go @@ -1,3 +1,9 @@ package main -func numberSquares() {} +func numberSquares(num int) int { + totalSquares := 0 + for k := 1; k <= num; k++ { + totalSquares += (num - k + 1) * (num - k + 1) + } + return totalSquares +} diff --git a/exercise1/problem4/main.go b/exercise1/problem4/main.go index 74af9044..9254bb75 100644 --- a/exercise1/problem4/main.go +++ b/exercise1/problem4/main.go @@ -1,3 +1,13 @@ package main -func detectWord() {} +func detectWord(str string) string { + res := "" + + for i := 0; i < len(str); i++ { + if str[i] >= 'a' && str[i] <= 'z' { + res = res + string(str[i]) + } + } + + return res +} diff --git a/exercise1/problem5/main.go b/exercise1/problem5/main.go index c5a804c9..bf4d5ddd 100644 --- a/exercise1/problem5/main.go +++ b/exercise1/problem5/main.go @@ -1,3 +1,16 @@ package main -func potatoes() {} +func potatoes(input string) int { + + str := "potato" + strLength := len(str) + count := 0 + + for i := 0; i <= len(input)-strLength; i++ { + if input[i:i+strLength] == str { + count++ + } + } + + return count +} \ No newline at end of file diff --git a/exercise1/problem6/main.go b/exercise1/problem6/main.go index 06043890..17845614 100644 --- a/exercise1/problem6/main.go +++ b/exercise1/problem6/main.go @@ -1,3 +1,27 @@ package main -func emojify() {} +func emojify(input string) string { + runes := []rune(input) + result := make([]rune, 0, len(runes)) + + for i := 0; i < len(runes); { + if i+4 < len(runes) && string(runes[i:i+5]) == "smile" { + result = append(result, '🙂') + i += 5 + } else if i+2 < len(runes) && string(runes[i:i+3]) == "sad" { + result = append(result, '😥') + i += 3 + } else if i+3 < len(runes) && string(runes[i:i+4]) == "grin" { + result = append(result, '😀') + i += 4 + } else if i+2 < len(runes) && string(runes[i:i+3]) == "mad" { + result = append(result, '😠') + i += 3 + } else { + result = append(result, runes[i]) + i++ + } + } + + return string(result) +} \ No newline at end of file diff --git a/exercise1/problem7/main.go b/exercise1/problem7/main.go index 57c99b5c..4ecab34d 100644 --- a/exercise1/problem7/main.go +++ b/exercise1/problem7/main.go @@ -1,3 +1,18 @@ package main -func highestDigit() {} +func highestDigit(num int) int { + + res := 0 + + for num > 0 { + + n := num % 10 + + if n > res { + + res = n + } + num = num / 10 + } + return res +} diff --git a/exercise1/problem8/main.go b/exercise1/problem8/main.go index 97fa0dae..7d01331c 100644 --- a/exercise1/problem8/main.go +++ b/exercise1/problem8/main.go @@ -1,3 +1,11 @@ package main -func countVowels() {} +func countVowels(input string) int { + count := 0 + for i := 0; i < len(input); i++ { + if input[i] == 'a' || input[i] == 'e' || input[i] == 'i' || input[i] == 'o' || input[i] == 'u' { + count++ + } + } + return count +} diff --git a/exercise1/problem9/main.go b/exercise1/problem9/main.go index e8c84a54..0a35a3d9 100644 --- a/exercise1/problem9/main.go +++ b/exercise1/problem9/main.go @@ -1,7 +1,11 @@ package main -func bitwiseAND() {} - -func bitwiseOR() {} - -func bitwiseXOR() {} +func bitwiseAND(a, b int) int { + return a & b +} +func bitwiseOR(a, b int) int { + return a | b +} +func bitwiseXOR(a, b int) int { + return a ^ b +} diff --git a/exercise2/problem1/problem1.go b/exercise2/problem1/problem1.go index 4763006c..5b57f4ec 100644 --- a/exercise2/problem1/problem1.go +++ b/exercise2/problem1/problem1.go @@ -1,4 +1,7 @@ package problem1 -func isChangeEnough() { +func isChangeEnough(arr [4]int, coast float32) bool { + sum := arr[0]*25 + arr[1]*10 + arr[2]*5 + arr[3]*1 + cena := coast * 100 + return sum >= int(cena) } diff --git a/exercise2/problem10/problem10.go b/exercise2/problem10/problem10.go index 7142a022..01392330 100644 --- a/exercise2/problem10/problem10.go +++ b/exercise2/problem10/problem10.go @@ -1,3 +1,17 @@ package problem10 -func factory() {} +func factory() (map[string]int, func(brand string) func(int)) { + brands := make(map[string]int) + + makeBrand := func(brand string) func(int) { + if _, exists := brands[brand]; !exists { + brands[brand] = 0 + } + + return func(count int) { + brands[brand] += count + } + } + + return brands, makeBrand +} diff --git a/exercise2/problem11/problem11.go b/exercise2/problem11/problem11.go index 33988711..6c2d1145 100644 --- a/exercise2/problem11/problem11.go +++ b/exercise2/problem11/problem11.go @@ -1,3 +1,15 @@ package problem11 -func removeDups() {} +func removeDups[T comparable](list []T) []T { + seen := map[T]struct{}{} + output := make([]T, 0, len(list)) + for _, v := range list { + if _, ok := seen[v]; ok { + continue + } + output = append(output, v) + seen[v] = struct{}{} + } + + return output +} diff --git a/exercise2/problem12/problem12.go b/exercise2/problem12/problem12.go index 4c1ae327..805a4dc3 100644 --- a/exercise2/problem12/problem12.go +++ b/exercise2/problem12/problem12.go @@ -1,3 +1,23 @@ package problem11 -func keysAndValues() {} +import "sort" + +func keysAndValues[K string | int, V comparable](mp map[K]V) ([]K, []V) { + + keys := make([]K, 0, len(mp)) + values := make([]V, 0, len(mp)) + + for k := range mp { + keys = append(keys, k) + } + + sort.Slice(keys, func(i, j int) bool { + return keys[i] < keys[j] + }) + + for _, k := range keys { + values = append(values, mp[k]) + } + + return keys, values +} diff --git a/exercise2/problem2/problem2.go b/exercise2/problem2/problem2.go index fdb199f0..49d28c40 100644 --- a/exercise2/problem2/problem2.go +++ b/exercise2/problem2/problem2.go @@ -1,4 +1,14 @@ package problem2 -func capitalize() { +import ( + "strings" +) + +func capitalize(names []string) []string { + capitalizedNames := make([]string, len(names)) + for i, name := range names { + + capitalizedNames[i] = strings.Title(strings.ToLower(name)) + } + return capitalizedNames } diff --git a/exercise2/problem3/problem3.go b/exercise2/problem3/problem3.go index f183fafb..a235ed6c 100644 --- a/exercise2/problem3/problem3.go +++ b/exercise2/problem3/problem3.go @@ -9,5 +9,24 @@ const ( lr dir = "lr" ) -func diagonalize() { +func diagonalize(n int, d dir) [][]int { + matrix := make([][]int, n) + for i := range matrix { + matrix[i] = make([]int, n) + } + for i := 0; i < n; i++ { + for j := 0; j < n; j++ { + switch d { + case ul: + matrix[i][j] = i + j + case ur: + matrix[i][j] = i + (n - 1 - j) + case ll: + matrix[i][j] = (n - 1 - i) + j + case lr: + matrix[i][j] = (n - 1 - i) + (n - 1 - j) + } + } + } + return matrix } diff --git a/exercise2/problem4/problem4.go b/exercise2/problem4/problem4.go index 1f680a4d..03b83532 100644 --- a/exercise2/problem4/problem4.go +++ b/exercise2/problem4/problem4.go @@ -1,4 +1,9 @@ package problem4 -func mapping() { +func mapping(letters []string) map[string]string { + result := make(map[string]string) + for _, letter := range letters { + result[letter] = string(letter[0] - 32) + } + return result } diff --git a/exercise2/problem5/problem5.go b/exercise2/problem5/problem5.go index 43fb96a4..c0b3b3da 100644 --- a/exercise2/problem5/problem5.go +++ b/exercise2/problem5/problem5.go @@ -1,4 +1,24 @@ package problem5 -func products() { +import ( + "sort" +) + +func products(productMap map[string]int, minPrice int) []string { + var filteredProducts []string + + for product, price := range productMap { + if price >= minPrice { + filteredProducts = append(filteredProducts, product) + } + } + + sort.Slice(filteredProducts, func(i, j int) bool { + if productMap[filteredProducts[i]] == productMap[filteredProducts[j]] { + return filteredProducts[i] < filteredProducts[j] + } + return productMap[filteredProducts[i]] > productMap[filteredProducts[j]] + }) + + return filteredProducts } diff --git a/exercise2/problem6/problem6.go b/exercise2/problem6/problem6.go index 89fc5bfe..29ee15a1 100644 --- a/exercise2/problem6/problem6.go +++ b/exercise2/problem6/problem6.go @@ -1,4 +1,12 @@ package problem6 -func sumOfTwo() { +func sumOfTwo(a []int, b []int, c int) bool { + for _, i := range a { + for _, j := range b { + if i+j == c { + return true + } + } + } + return false } diff --git a/exercise2/problem7/problem7.go b/exercise2/problem7/problem7.go index 32514209..78fec824 100644 --- a/exercise2/problem7/problem7.go +++ b/exercise2/problem7/problem7.go @@ -1,4 +1,15 @@ package problem7 -func swap() { +import ( + "fmt" +) + +func swap(x *int, y *int) { + *x, *y = *y, *x +} + +func main() { + a, b := 1, 2 + swap(&a, &b) + fmt.Println(a, b) } diff --git a/exercise2/problem8/problem8.go b/exercise2/problem8/problem8.go index 9389d3b0..5f4db80a 100644 --- a/exercise2/problem8/problem8.go +++ b/exercise2/problem8/problem8.go @@ -1,16 +1,13 @@ package problem8 func simplify(list []string) map[string]int { - var indMap map[string]int - - indMap = make(map[string]int) - load(&indMap, &list) - + indMap := make(map[string]int) + load(&indMap, list) return indMap } -func load(m *map[string]int, students *[]string) { - for i, name := range *students { +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..c4d49a6e 100644 --- a/exercise2/problem9/problem9.go +++ b/exercise2/problem9/problem9.go @@ -1,3 +1,11 @@ package problem9 -func factory() {} +func factory(a int) func(nums ...int) []int { + return func(nums ...int) []int { + res := make([]int, len(nums)) + for i, num := range nums { + res[i] = num * a + } + return res + } +} diff --git a/exercise3/problem1/problem1.go b/exercise3/problem1/problem1.go index d45605c6..57fd6610 100644 --- a/exercise3/problem1/problem1.go +++ b/exercise3/problem1/problem1.go @@ -1,3 +1,37 @@ package problem1 -type Queue struct{} +import "errors" + +type Queue struct { + arr []any +} + +func (q *Queue) Enqueue(element any) { + q.arr = append(q.arr, element) +} + +func (q *Queue) Dequeue() (any, error) { + if q.IsEmpty() { + var nullValue any + return nullValue, errors.New("queue is empty") + } + val := q.arr[0] + q.arr = q.arr[1:] + return val, nil +} + +func (q *Queue) Peek() (any, error) { + if q.IsEmpty() { + var nullValue any + return nullValue, errors.New("queue is empty") + } + return q.arr[0], nil +} + +func (q *Queue) Size() int { + return len(q.arr) +} + +func (q *Queue) IsEmpty() bool { + return q.Size() == 0 +} diff --git a/exercise3/problem2/problem2.go b/exercise3/problem2/problem2.go index e9059889..0920c155 100644 --- a/exercise3/problem2/problem2.go +++ b/exercise3/problem2/problem2.go @@ -1,3 +1,34 @@ package problem2 -type Stack struct{} +import "errors" + +type Stack struct { + arr []any +} + +func (s *Stack) Push(a any) { + s.arr = append(s.arr, a) +} + +func (s *Stack) Pop() (any, error) { + last, err := s.Peek() + if err == nil { + s.arr = s.arr[:s.Size()-1] + } + return last, err +} + +func (s *Stack) Peek() (any, error) { + if s.IsEmpty() { + return nil, errors.New("Stack empty") + } + return s.arr[len(s.arr)-1], nil +} + +func (s *Stack) Size() int { + return len(s.arr) +} + +func (s *Stack) IsEmpty() bool { + return s.Size() == 0 +} diff --git a/exercise3/problem3/problem3.go b/exercise3/problem3/problem3.go index d8d79ac0..cccad37a 100644 --- a/exercise3/problem3/problem3.go +++ b/exercise3/problem3/problem3.go @@ -1,3 +1,113 @@ package problem3 -type Set struct{} +type Set struct { + items []any +} + +func NewSet() *Set { + return &Set{items: make([]any, 0)} +} + +func (s *Set) Add(a any) { + if !s.Has(a) { + s.items = append(s.items, a) + } +} + +func (s *Set) Remove(a any) { + if s.IsEmpty() { + return + } + + var num int + for i, ch := range s.items { + if ch == a { + num = i + } + } + + s.items[num] = s.items[s.Size()-1] + s.items = s.items[:s.Size()-1] +} + +func (s *Set) IsEmpty() bool { + return s.Size() == 0 +} + +func (s *Set) Size() int { + return len(s.items) +} + +func (s *Set) List() []any { + return s.items +} + +func (s *Set) Has(item any) bool { + for _, v := range s.items { + if v == item { + return true + } + } + + return false +} + +func (s *Set) Copy() *Set { + cp := make([]any, s.Size()) + copy(s.items, cp) + return &Set{items: cp} +} + +func (s1 *Set) Difference(s2 *Set) *Set { + diff := make([]any, 0) + for _, v1 := range s1.items { + if !s2.Has(v1) { + diff = append(diff, v1) + } + } + + return &Set{items: diff} +} + +func (sub *Set) IsSubset(s *Set) bool { + for _, v := range sub.items { + if !s.Has(v) { + return false + } + } + + return true +} + +func Union(sets ...*Set) *Set { + un := NewSet() + for _, v := range sets { + for _, v1 := range v.items { + un.Add(v1) + } + } + + return un +} + +func Intersect(sets ...*Set) *Set { + in := NewSet() + if len(sets) == 0 { + return in + } + + if len(sets) == 1 { + return sets[0] + } + + for _, v := range sets[0].items { + in.Add(v) + for _, v1 := range sets[1:] { + if !v1.Has(v) { + in.Remove(v) + } + } + } + + return in +} diff --git a/exercise3/problem4/problem4.go b/exercise3/problem4/problem4.go index ebf78147..a284f700 100644 --- a/exercise3/problem4/problem4.go +++ b/exercise3/problem4/problem4.go @@ -1,3 +1,110 @@ package problem4 -type LinkedList struct{} +import ( + "errors" + "fmt" +) + +type Element[T comparable] struct { + value T + next *Element[T] +} + +type LinkedList[T comparable] struct { + head *Element[T] +} + +func (list *LinkedList[T]) Add(element *Element[T]) { + newElement := element + + if list.head == nil { + list.head = newElement + } else { + current := list.head + for current.next != nil { + current = current.next + } + current.next = newElement + } +} + +func (list *LinkedList[T]) Insert(element *Element[T], index int) error { + if index < 0 || index > list.Size() { + return errors.New("index out of range") + } + + i := 1 + if index == i { + temp := list.head + list.head = element + list.head.next = temp + return nil + } + + current := list.head + for current.next != nil { + i++ + fmt.Println(current.value) + if i == index { + temp := current.next + current.next = element + current.next.next = temp + return nil + } + current = current.next + } + return nil +} + +func (list *LinkedList[T]) Delete(element *Element[T]) error { + if list.head.value == element.value { + list.head = list.head.next + } + current := list.head + if current == nil { + return errors.New("list is empty") + } + for current.next != nil { + if current.next.value == element.value { + current.next = current.next.next + } + current = current.next + } + return errors.New("element to delete not found") +} + +func (list *LinkedList[T]) Find(value any) (Element[T], error) { + if list.head.value == value { + return *list.head, nil + } + current := list.head + for current.next != nil { + if current.next.value == value { + return *current.next, nil + } + current = current.next + } + return Element[T]{}, errors.New("element not found") +} + +func (list *LinkedList[T]) List() []T { + var result []T + if list.head == nil { + return result + } + current := list.head + result = append(result, current.value) + for current.next != nil { + current = current.next + result = append(result, current.value) + } + return result +} + +func (list *LinkedList[T]) Size() int { + return len(list.List()) +} + +func (list *LinkedList[T]) IsEmpty() bool { + return list.Size() == 0 +} diff --git a/exercise3/problem5/problem5.go b/exercise3/problem5/problem5.go index 4177599f..e2e8e64f 100644 --- a/exercise3/problem5/problem5.go +++ b/exercise3/problem5/problem5.go @@ -1,3 +1,17 @@ package problem5 -type Person struct{} +import "fmt" + +type Person struct { + name string + age int +} + +func (p *Person) compareAge(per *Person) string { + if p.age > per.age { + return fmt.Sprintf("%s is younger than me.", per.name) + } else if p.age < per.age { + return fmt.Sprintf("%s is older than me.", per.name) + } + return fmt.Sprintf("%s is the same age as me.", per.name) +} diff --git a/exercise3/problem6/problem6.go b/exercise3/problem6/problem6.go index 4e8d1af8..bf7d2a78 100644 --- a/exercise3/problem6/problem6.go +++ b/exercise3/problem6/problem6.go @@ -1,7 +1,21 @@ package problem6 -type Animal struct{} +type Animal struct { + name string + legsNum int +} -type Insect struct{} +type Insect struct { + name string + legsNum int +} -func sumOfAllLegsNum() {} +func sumOfAllLegsNum(horse, kangaroo *Animal, ant, spider *Insect) int { + c := 0 + + c += horse.legsNum + c += kangaroo.legsNum + c += ant.legsNum + c += spider.legsNum + return c +} diff --git a/exercise3/problem7/problem7.go b/exercise3/problem7/problem7.go index 26887151..ba838e2f 100644 --- a/exercise3/problem7/problem7.go +++ b/exercise3/problem7/problem7.go @@ -1,10 +1,27 @@ package problem7 type BankAccount struct { + name string + balance int } type FedexAccount struct { + name string + packages []string } type KazPostAccount struct { + name string + balance int + packages []string +} + +func withdrawMoney(num int, client1 *BankAccount, client2 *KazPostAccount) { + client1.balance -= num + client2.balance -= num +} + +func sendPackagesTo(name string, client1 *FedexAccount, client2 *KazPostAccount) { + client1.packages = append(client1.packages, client1.name+" send package to "+name) + client2.packages = append(client2.packages, client2.name+" send package to "+name) } diff --git a/exercise4/bot/game/move/checkWin.go b/exercise4/bot/game/move/checkWin.go new file mode 100644 index 00000000..2df33501 --- /dev/null +++ b/exercise4/bot/game/move/checkWin.go @@ -0,0 +1,17 @@ +package move + +func isWinning(board []string, token string) bool { + winningPositions := [][]int{ + {0, 1, 2}, {3, 4, 5}, {6, 7, 8}, // rows + {0, 3, 6}, {1, 4, 7}, {2, 5, 8}, // columns + {0, 4, 8}, {2, 4, 6}, // diagonals + } + for _, positions := range winningPositions { + if board[positions[0]] == token && + board[positions[1]] == token && + board[positions[2]] == token { + return true + } + } + return false +} diff --git a/exercise4/bot/game/move/move.go b/exercise4/bot/game/move/move.go new file mode 100644 index 00000000..9f0e1b82 --- /dev/null +++ b/exercise4/bot/game/move/move.go @@ -0,0 +1,71 @@ +package move + +import "math" + +func GetBestMove(board []string, token string) int { + bestScore := math.Inf(-1) + bestMove := -1 + + for i := 0; i < 9; i++ { + if board[i] == " " { + board[i] = token + score := minimax(board, 0, false, token) + board[i] = " " + if score > bestScore { + bestScore = score + bestMove = i + } + } + } + return bestMove +} + +func minimax(board []string, depth int, isMaximizing bool, token string) float64 { + opponentToken := "o" + if token == "o" { + opponentToken = "x" + } + + if isWinning(board, token) { + return 1 + } + if isWinning(board, opponentToken) { + return -1 + } + if isBoardFull(board) { + return 0 + } + + if isMaximizing { + bestScore := math.Inf(-1) + for i := 0; i < 9; i++ { + if board[i] == " " { + board[i] = token + score := minimax(board, depth+1, false, token) + board[i] = " " + bestScore = math.Max(bestScore, score) + } + } + return bestScore + } else { + bestScore := math.Inf(1) + for i := 0; i < 9; i++ { + if board[i] == " " { + board[i] = opponentToken + score := minimax(board, depth+1, true, token) + board[i] = " " + bestScore = math.Min(bestScore, score) + } + } + return bestScore + } +} + +func isBoardFull(board []string) bool { + for _, cell := range board { + if cell == " " { + return false + } + } + return true +} diff --git a/exercise4/bot/main.go b/exercise4/bot/main.go index 64f9e0a3..d0381e4f 100644 --- a/exercise4/bot/main.go +++ b/exercise4/bot/main.go @@ -1,16 +1,57 @@ package main import ( + "bytes" "context" + "encoding/json" + "fmt" + "net/http" "os" "os/signal" "syscall" + "time" ) +func sendRequest(ctx context.Context) { + port := os.Getenv("PORT") + name := os.Getenv("NAME") + js, err := json.Marshal(map[string]any{ + "name": name, + "url": fmt.Sprintf("http://127.0.0.1:%s", port), + }) + if err != nil { + fmt.Println("error Marshal") + return + } + + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + fmt.Sprintf("%s/join", "http://127.0.0.1:4444"), + bytes.NewBuffer(js), + ) + if err != nil { + fmt.Println(err) + fmt.Println("error NewRequestWithContext") + return + } + + client := &http.Client{} + res, err := client.Do(req) + if err != nil { + fmt.Println(err) + fmt.Println("Error send join") + return + } + fmt.Println(res.StatusCode) +} + func main() { - ctx := context.Background() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() - ready := startServer() + ready := startServer(ctx) + sendRequest(ctx) <-ready // TODO after server start diff --git a/exercise4/bot/server.go b/exercise4/bot/server.go index e6760ec5..06d2cfb8 100644 --- a/exercise4/bot/server.go +++ b/exercise4/bot/server.go @@ -1,13 +1,21 @@ package main import ( + "bytes" + "context" + "encoding/json" "errors" "fmt" + "io" + "log/slog" "net" "net/http" "os" + "strings" "sync" "time" + + "github.com/talgat-ruby/exercises-go/exercise4/bot/game/move" ) type readyListener struct { @@ -21,21 +29,111 @@ func (l *readyListener) Accept() (net.Conn, error) { return l.Listener.Accept() } -func startServer() <-chan struct{} { - ready := make(chan struct{}) +type RequestMove struct { + Board []string `json:"board"` + Token string `json:"token"` +} - listener, err := net.Listen("tcp", fmt.Sprintf(":%s", os.Getenv("PORT"))) +func pingHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + w.WriteHeader(http.StatusOK) +} + +func moveHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + fmt.Println("Received request:", r.Method, r.URL.Path) + + bodyBytes, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Can't read body", http.StatusBadRequest) + return + } + fmt.Println("Request Body:", string(bodyBytes)) + + // Reset the request body + r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + + var reqMove RequestMove + if err := json.Unmarshal(bodyBytes, &reqMove); err != nil { + http.Error(w, "Bad request: "+err.Error(), http.StatusBadRequest) + return + } + + board := reqMove.Board + token := strings.ToLower(reqMove.Token) // tokens are lowercase "x" or "o" + + if len(board) != 9 { + http.Error(w, "Invalid board size", http.StatusBadRequest) + return + } + + if token != "x" && token != "o" { + http.Error(w, "Invalid token", http.StatusBadRequest) + return + } + + index := move.GetBestMove(board, token) + if index == -1 { + http.Error(w, "No moves left", http.StatusBadRequest) + return + } + + response := map[string]int{"index": index} + js, err := json.Marshal(response) + if err != nil { + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, err = w.Write(js) + if err != nil { + fmt.Printf("Failed to write response: %v\n", err) + + } +} + +func startServer(ctx context.Context) <-chan struct{} { + port := os.Getenv("PORT") + if port == "" { + port = "4081" + } + mux := http.NewServeMux() + mux.HandleFunc("/ping", pingHandler) + mux.HandleFunc("/move", moveHandler) + + server := &http.Server{ + Handler: mux, + ReadTimeout: 5 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 15 * time.Second, + BaseContext: func(_ net.Listener) context.Context { + return ctx + }, + } + + listener, err := net.Listen("tcp", fmt.Sprintf(":%s", port)) if err != nil { panic(err) } + ready := make(chan struct{}) list := &readyListener{Listener: listener, ready: ready} - srv := &http.Server{ - IdleTimeout: 2 * time.Minute, - } + + slog.InfoContext( + ctx, + "starting service", + "port", port, + ) go func() { - err := srv.Serve(list) + err := server.Serve(list) if !errors.Is(err, http.ErrServerClosed) { panic(err) } diff --git a/exercise4/judge/internal/ticTacToe/match/start.go b/exercise4/judge/internal/ticTacToe/match/start.go index aca76320..50fd03b6 100644 --- a/exercise4/judge/internal/ticTacToe/match/start.go +++ b/exercise4/judge/internal/ticTacToe/match/start.go @@ -63,9 +63,8 @@ func (m *Match) totalRoundsWonBy(p *player.Player) int { total := 0 for _, r := range m.Rounds { - if r.Winner != nil && r.Winner.URL == p.URL { + if r.Winner.URL == p.URL { total += 1 - break } } diff --git a/exercise5/problem1/problem1.go b/exercise5/problem1/problem1.go index 4f514fab..b10278d6 100644 --- a/exercise5/problem1/problem1.go +++ b/exercise5/problem1/problem1.go @@ -1,9 +1,16 @@ package problem1 +import "sync" + func incrementConcurrently(num int) int { + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() num++ }() + wg.Wait() return num } diff --git a/exercise5/problem3/problem3.go b/exercise5/problem3/problem3.go index e085a51a..d7177f0c 100644 --- a/exercise5/problem3/problem3.go +++ b/exercise5/problem3/problem3.go @@ -1,11 +1,12 @@ package problem3 func sum(a, b int) int { - var c int + // var c int + c := make(chan int) go func(a, b int) { - c = a + b + c <- a + b }(a, b) - return c + return <-c } diff --git a/exercise5/problem4/problem4.go b/exercise5/problem4/problem4.go index b5899ddf..6c05ac2e 100644 --- a/exercise5/problem4/problem4.go +++ b/exercise5/problem4/problem4.go @@ -4,6 +4,7 @@ func iter(ch chan<- int, nums []int) { for _, n := range nums { ch <- n } + close(ch) } func sum(nums []int) int { diff --git a/exercise5/problem5/problem5.go b/exercise5/problem5/problem5.go index ac192c58..4285f858 100644 --- a/exercise5/problem5/problem5.go +++ b/exercise5/problem5/problem5.go @@ -1,8 +1,21 @@ package problem5 -func producer() {} +import "strings" -func consumer() {} +func producer(words []string, ch chan<- string) { + for _, word := range words { + ch <- word + } + close(ch) +} + +func consumer(ch <-chan string) string { + var message []string + for word := range ch { + message = append(message, word) + } + return strings.Join(message, " ") +} func send( words []string, diff --git a/exercise5/problem6/problem6.go b/exercise5/problem6/problem6.go index e1beea87..8762e8c7 100644 --- a/exercise5/problem6/problem6.go +++ b/exercise5/problem6/problem6.go @@ -2,8 +2,32 @@ package problem6 type pipe func(in <-chan int) <-chan int -var multiplyBy2 pipe = func() {} +var multiplyBy2 pipe = func(in <-chan int) <-chan int { + out := make(chan int) + go func() { + for n := range in { + out <- n * 2 + } + close(out) + }() + return out +} -var add5 pipe = func() {} +var add5 pipe = func(in <-chan int) <-chan int { + out := make(chan int) + go func() { + for n := range in { + out <- n + 5 + } + close(out) + }() + return out +} -func piper(in <-chan int, pipes []pipe) <-chan int {} +func piper(in <-chan int, pipes []pipe) <-chan int { + out := in + for _, p := range pipes { + out = p(out) + } + return out +} diff --git a/exercise5/problem7/problem7.go b/exercise5/problem7/problem7.go index c3c1d0c9..302a4981 100644 --- a/exercise5/problem7/problem7.go +++ b/exercise5/problem7/problem7.go @@ -1,3 +1,31 @@ package problem7 -func multiplex(ch1 <-chan string, ch2 <-chan string) []string {} +func multiplex(ch1 <-chan string, ch2 <-chan string) []string { + var result []string + done := make(chan chan struct{}) + + go func() { + for { + select { + case val, ok := <-ch1: + if ok { + result = append(result, val) + } else { + ch1 = nil + } + case val, ok := <-ch2: + if ok { + result = append(result, val) + } else { + ch2 = nil + } + } + if ch1 == nil && ch2 == nil { + break + } + } + close(done) + }() + <-done + return result +} diff --git a/exercise5/problem8/problem8.go b/exercise5/problem8/problem8.go index 3e951b3b..cde2d620 100644 --- a/exercise5/problem8/problem8.go +++ b/exercise5/problem8/problem8.go @@ -4,4 +4,11 @@ import ( "time" ) -func withTimeout(ch <-chan string, ttl time.Duration) string {} +func withTimeout(ch <-chan string, ttl time.Duration) string { + select { + case msg := <-ch: + return msg + case <-time.After(ttl): + return "timeout" + } +} diff --git a/exercise6/problem1/problem1.go b/exercise6/problem1/problem1.go index ee453b24..d38c53c0 100644 --- a/exercise6/problem1/problem1.go +++ b/exercise6/problem1/problem1.go @@ -1,9 +1,28 @@ package problem1 +import ( + "sync" +) + type bankAccount struct { + mu sync.Mutex blnc int } func newAccount(blnc int) *bankAccount { - return &bankAccount{blnc} + return &bankAccount{blnc: blnc} +} + +func (a *bankAccount) deposit(amount int) { + a.mu.Lock() + defer a.mu.Unlock() + a.blnc += amount +} + +func (a *bankAccount) withdraw(amount int) { + a.mu.Lock() + defer a.mu.Unlock() + if a.blnc >= amount { + a.blnc -= amount + } } diff --git a/exercise6/problem2/problem2.go b/exercise6/problem2/problem2.go index 97e02368..6a3da6b1 100644 --- a/exercise6/problem2/problem2.go +++ b/exercise6/problem2/problem2.go @@ -1,20 +1,38 @@ package problem2 import ( + "sync" "time" ) var readDelay = 10 * time.Millisecond type bankAccount struct { + mu sync.Mutex blnc int } func newAccount(blnc int) *bankAccount { - return &bankAccount{blnc} + return &bankAccount{blnc: blnc} } -func (b *bankAccount) balance() int { +func (a *bankAccount) deposit(amount int) { + a.mu.Lock() + defer a.mu.Unlock() + a.blnc += amount +} + +func (a *bankAccount) withdraw(amount int) { + a.mu.Lock() + defer a.mu.Unlock() + if a.blnc >= amount { + a.blnc -= amount + } +} + +func (a *bankAccount) balance() int { time.Sleep(readDelay) - return 0 + a.mu.Lock() + defer a.mu.Unlock() + return a.blnc } diff --git a/exercise6/problem3/problem3.go b/exercise6/problem3/problem3.go index b34b90bb..acb946e2 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 atomic.LoadInt64(&c.val) +} diff --git a/exercise6/problem4/problem4.go b/exercise6/problem4/problem4.go index 793449c9..c8ec4120 100644 --- a/exercise6/problem4/problem4.go +++ b/exercise6/problem4/problem4.go @@ -1,31 +1,44 @@ 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, ch chan<- int, cond *sync.Cond) { + defer cond.L.Unlock() + cond.L.Lock() + + if len(*shoppingList) == 0 { + cond.Wait() + } + ch <- id } -func updateShopList(shoppingList *[]string) { +func updateShopList(shoppingList *[]string, cond *sync.Cond) { time.Sleep(10 * time.Millisecond) + defer cond.L.Unlock() + cond.L.Lock() + *shoppingList = append(*shoppingList, "apples") *shoppingList = append(*shoppingList, "milk") *shoppingList = append(*shoppingList, "bake soda") + + cond.Signal() } func notifyOnShopListUpdate(shoppingList *[]string, numWorkers int) <-chan int { notifier := make(chan int) + cond := &sync.Cond{L: &sync.Mutex{}} for i := range numWorkers { - go worker(i+1, shoppingList, notifier) - time.Sleep(time.Millisecond) // order matters + go worker(i+1, shoppingList, notifier, cond) + time.Sleep(time.Millisecond) } - go updateShopList(shoppingList) + go updateShopList(shoppingList, cond) return notifier } diff --git a/exercise6/problem5/problem5.go b/exercise6/problem5/problem5.go index 8e4a1703..58866f32 100644 --- a/exercise6/problem5/problem5.go +++ b/exercise6/problem5/problem5.go @@ -1,31 +1,42 @@ 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, ch chan<- int, cond *sync.Cond) { + defer cond.L.Unlock() + cond.L.Lock() + for len(*shoppingList) == 0 { + cond.Wait() + } ch <- id } -func updateShopList(shoppingList *[]string) { +func updateShopList(shoppingList *[]string, cond *sync.Cond) { time.Sleep(10 * time.Millisecond) + defer cond.L.Unlock() + cond.L.Lock() + *shoppingList = append(*shoppingList, "apples") *shoppingList = append(*shoppingList, "milk") *shoppingList = append(*shoppingList, "bake soda") + + cond.Broadcast() } func notifyOnShopListUpdate(shoppingList *[]string, numWorkers int) <-chan int { notifier := make(chan int) + cond := &sync.Cond{L: &sync.Mutex{}} for i := range numWorkers { - go worker(i+1, shoppingList, notifier) - time.Sleep(time.Millisecond) // order matters + go worker(i+1, shoppingList, notifier, cond) + time.Sleep(time.Millisecond) } - go updateShopList(shoppingList) + go updateShopList(shoppingList, cond) return notifier } diff --git a/exercise6/problem6/problem6.go b/exercise6/problem6/problem6.go index 0c1122b9..fcbf38a7 100644 --- a/exercise6/problem6/problem6.go +++ b/exercise6/problem6/problem6.go @@ -6,14 +6,13 @@ import ( func runTasks(init func()) { var wg sync.WaitGroup + onceInit := sync.OnceFunc(init) for range 10 { wg.Add(1) go func() { defer wg.Done() - - //TODO: modify so that load function gets called only once. - init() + onceInit() }() } wg.Wait() diff --git a/exercise6/problem7/problem7.go b/exercise6/problem7/problem7.go index ef49497b..88a9ea76 100644 --- a/exercise6/problem7/problem7.go +++ b/exercise6/problem7/problem7.go @@ -3,17 +3,27 @@ package problem7 import ( "fmt" "math/rand" + "sync" "time" ) func task() { start := time.Now() var t *time.Timer + mx := &sync.Mutex{} + t = time.AfterFunc( randomDuration(), func() { + defer mx.Unlock() + mx.Lock() fmt.Println(time.Now().Sub(start)) t.Reset(randomDuration()) + + func() { + defer mx.Unlock() + mx.Lock() + }() }, ) time.Sleep(5 * time.Second) diff --git a/exercise6/problem8/problem8.go b/exercise6/problem8/problem8.go index 949eb2d2..405ea79d 100644 --- a/exercise6/problem8/problem8.go +++ b/exercise6/problem8/problem8.go @@ -1,3 +1,30 @@ package problem8 -func multiplex(chs []<-chan string) []string {} +func multiplex(chs []<-chan string) []string { + var result []string + done := make(chan struct{}) + resultChan := make(chan string) + remaining := len(chs) + + for _, ch := range chs { + go func(c <-chan string) { + for val := range c { + resultChan <- val + } + remaining-- + if remaining == 0 { + close(resultChan) + } + }(ch) + } + + go func() { + for val := range resultChan { + result = append(result, val) + } + close(done) + }() + + <-done + return result +} diff --git a/exercise7/blogging-platform/.env b/exercise7/blogging-platform/.env new file mode 100644 index 00000000..f89c0b3d --- /dev/null +++ b/exercise7/blogging-platform/.env @@ -0,0 +1,8 @@ +API_HOST=localhost +API_PORT=4010 + +DB_NAME=blog +DB_USER=postgres +DB_PASSWORD=postgres +DB_PORT=5432 +DB_HOST=192.168.99.100 \ No newline at end of file diff --git a/exercise7/blogging-platform/Dockerfile b/exercise7/blogging-platform/Dockerfile new file mode 100644 index 00000000..54b85cf9 --- /dev/null +++ b/exercise7/blogging-platform/Dockerfile @@ -0,0 +1,19 @@ +FROM golang:1.23-alpine as builder + +WORKDIR /app + +COPY . . + +RUN go mod tidy + +RUN go build -o myapp . + +FROM alpine:latest + +RUN apk add --no-cache libpq + +COPY --from=builder /app/myapp /app/myapp + +ENTRYPOINT ["/app/myapp"] + +EXPOSE 8080 \ 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/docker-compose.yml b/exercise7/blogging-platform/docker-compose.yml new file mode 100644 index 00000000..82bc18c4 --- /dev/null +++ b/exercise7/blogging-platform/docker-compose.yml @@ -0,0 +1,32 @@ +version: '3' + +services: + app: + build: . + ports: + - ${API_PORT}:8080 + environment: + - DB_HOST=db + - DB_PORT=${DB_PORT} + - DB_USER=${DB_USER} + - DB_PASSWORD=${DB_PASSWORD} + - DB_NAME=${DB_NAME} + - API_PORT=8080 + depends_on: + - db + + db: + image: postgres:17-alpine + environment: + - POSTGRES_USER=${DB_USER} + - POSTGRES_PASSWORD=${DB_PASSWORD} + - POSTGRES_DB=${DB_NAME} + ports: + - ${DB_PORT}:5432 + volumes: + - pg_data:/var/lib/postgresql/data + +volumes: + pg_data: + driver: local + diff --git a/exercise7/blogging-platform/go.mod b/exercise7/blogging-platform/go.mod new file mode 100644 index 00000000..7cd5a99e --- /dev/null +++ b/exercise7/blogging-platform/go.mod @@ -0,0 +1,8 @@ +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 +) diff --git a/exercise7/blogging-platform/go.sum b/exercise7/blogging-platform/go.sum new file mode 100644 index 00000000..ecb9035f --- /dev/null +++ b/exercise7/blogging-platform/go.sum @@ -0,0 +1,4 @@ +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/exercise7/blogging-platform/internal/api/handler/categories/all_categories.go b/exercise7/blogging-platform/internal/api/handler/categories/all_categories.go new file mode 100644 index 00000000..0360f297 --- /dev/null +++ b/exercise7/blogging-platform/internal/api/handler/categories/all_categories.go @@ -0,0 +1,29 @@ +package categories + +import ( + "fmt" + "net/http" + + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/pkg/httputils/response" +) + +func (c *Categories) GetAllCategories(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := c.logger.With("method", "GetAllCategories") + + categories, err := c.db.GetAllCategories(ctx) + if err != nil { + log.ErrorContext(ctx, "failed to get categories", "error", err) + http.Error(w, fmt.Sprintf("Failed to get categories: %s", err.Error()), http.StatusInternalServerError) + return + } + + if err := response.JSON(w, http.StatusOK, categories); err != nil { + log.ErrorContext(ctx, "failed to encode response", "error", err) + http.Error(w, fmt.Sprintf("Failed to encode response: %v", err), http.StatusInternalServerError) + return + } + + log.InfoContext(ctx, "success get all categories", "number of categories", len(categories)) + +} diff --git a/exercise7/blogging-platform/internal/api/handler/categories/all_posts_of_category.go b/exercise7/blogging-platform/internal/api/handler/categories/all_posts_of_category.go new file mode 100644 index 00000000..d84088ff --- /dev/null +++ b/exercise7/blogging-platform/internal/api/handler/categories/all_posts_of_category.go @@ -0,0 +1,46 @@ +package categories + +import ( + "fmt" + "net/http" + "strconv" + + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/pkg/httputils/response" +) + +func (c *Categories) GetAllPostsOfCategory(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := c.logger.With("method", "GetAllPostsOfCategory") + + idString := r.URL.Path[len("/categories/"):] + idString = idString[:len(idString)-6] + id, err := strconv.Atoi(idString) + if err != nil { + log.ErrorContext(ctx, "invalid category ID", "error", err) + http.Error(w, "Invalid category ID", http.StatusBadRequest) + return + } + posts, err := c.db.GetAllPostsOfCategory(ctx, id) + if err != nil { + log.ErrorContext(ctx, "failed to get posts by category", "error", err) + http.Error(w, fmt.Sprintf("Failed to get posts: %s", err.Error()), http.StatusInternalServerError) + return + } + + if len(posts) == 0 { + log.InfoContext(ctx, "no posts found for category", "category_id", id) + http.Error(w, "404 Not Found", http.StatusNotFound) + return + } + + if err := response.JSON(w, http.StatusOK, posts); err != nil { + log.ErrorContext(ctx, "failed to encode response", "error", err) + http.Error(w, fmt.Sprintf("Failed to encode response: %v", err), http.StatusInternalServerError) + return + } + + log.InfoContext( + ctx, "success get all posts of category", + "category_id", id, + "number of posts", len(posts)) +} diff --git a/exercise7/blogging-platform/internal/api/handler/categories/delete_category.go b/exercise7/blogging-platform/internal/api/handler/categories/delete_category.go new file mode 100644 index 00000000..0d49517d --- /dev/null +++ b/exercise7/blogging-platform/internal/api/handler/categories/delete_category.go @@ -0,0 +1,35 @@ +package categories + +import ( + "fmt" + "net/http" + "strconv" +) + +func (c *Categories) DeleteCategory(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := c.logger.With("method", "DeleteCategory") + + idString := r.URL.Path[len("/categories/"):] + id, err := strconv.Atoi(idString) + if err != nil { + log.ErrorContext(ctx, "invalid category ID", "error", err) + http.Error(w, "Invalid category ID", http.StatusBadRequest) + return + } + + err = c.db.DeleteCategory(ctx, id) + if err != nil { + if err.Error() == fmt.Sprintf("category with id %d not found", id) { + log.ErrorContext(ctx, "category not found", "category_id", id) + http.Error(w, "404 Not Found", http.StatusNotFound) + } else { + log.ErrorContext(ctx, "failed to delete category", "error", err) + http.Error(w, fmt.Sprintf("Failed to delete category: %s", err.Error()), http.StatusInternalServerError) + } + return + } + + log.InfoContext(ctx, "success delete category", "category_id", id) + w.WriteHeader(http.StatusNoContent) +} diff --git a/exercise7/blogging-platform/internal/api/handler/categories/info_category.go b/exercise7/blogging-platform/internal/api/handler/categories/info_category.go new file mode 100644 index 00000000..834f339b --- /dev/null +++ b/exercise7/blogging-platform/internal/api/handler/categories/info_category.go @@ -0,0 +1,39 @@ +package categories + +import ( + "fmt" + "net/http" + "strconv" + + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/pkg/httputils/response" +) + +func (c *Categories) GetInformationOfCategory(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := c.logger.With("method", "GetInformationOfCategory") + + idString := r.URL.Path[len("/categories/"):] + id, err := strconv.Atoi(idString) + if err != nil { + log.ErrorContext(ctx, "invalid category ID", "error", err) + http.Error(w, "Invalid category ID", http.StatusBadRequest) + return + } + + category, err := c.db.GetInformationOfCategory(ctx, id) + if err != nil || category == nil { + log.ErrorContext(ctx, "category not found", "category_id", id) + http.Error(w, "404 Not Found", http.StatusNotFound) + return + } + + if err := response.JSON(w, http.StatusOK, category); err != nil { + log.ErrorContext(ctx, "failed to encode response", "error", err) + http.Error(w, fmt.Sprintf("Failed to encode response: %v", err), http.StatusInternalServerError) + return + } + + log.InfoContext( + ctx, "success get information of category", + "category_id", id) +} diff --git a/exercise7/blogging-platform/internal/api/handler/categories/insert_category.go b/exercise7/blogging-platform/internal/api/handler/categories/insert_category.go new file mode 100644 index 00000000..2c85fbce --- /dev/null +++ b/exercise7/blogging-platform/internal/api/handler/categories/insert_category.go @@ -0,0 +1,44 @@ +package categories + +import ( + "fmt" + "net/http" + + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/internal/db/blog" + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/pkg/httputils/request" + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/pkg/httputils/response" +) + +func (c *Categories) InsertCategory(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := c.logger.With("method", "InsertCategory") + + //w.Header().Set("Content-Type", "application/json") + + var cat blog.CategoryRequest + if err := request.JSON(w, r, &cat); err != nil { + log.ErrorContext(ctx, "invalid request body", "error", err) + http.Error(w, fmt.Sprintf("Invalid request body: %v", err), http.StatusBadRequest) + return + } + + category, err := c.db.InsertCategory(ctx, cat) + if err != nil { + log.ErrorContext(ctx, "failed to create category", "error", err) + http.Error(w, fmt.Sprintf("Failed to create category: %v", err), http.StatusInternalServerError) + return + } + + if err := response.JSON(w, http.StatusCreated, category); err != nil { + log.ErrorContext(ctx, "failed to encode response", "error", err) + http.Error(w, fmt.Sprintf("Failed to encode response: %v", err), http.StatusInternalServerError) + return + } + + log.InfoContext( + ctx, + "success create category", + "category", category.String(), + ) + +} diff --git a/exercise7/blogging-platform/internal/api/handler/categories/main.go b/exercise7/blogging-platform/internal/api/handler/categories/main.go new file mode 100644 index 00000000..64e039c4 --- /dev/null +++ b/exercise7/blogging-platform/internal/api/handler/categories/main.go @@ -0,0 +1,19 @@ +package categories + +import ( + "log/slog" + + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/internal/db" +) + +type Categories struct { + logger *slog.Logger + db *db.DB +} + +func New(logger *slog.Logger, db *db.DB) *Categories { + return &Categories{ + logger: logger, + db: db, + } +} diff --git a/exercise7/blogging-platform/internal/api/handler/categories/update_category.go b/exercise7/blogging-platform/internal/api/handler/categories/update_category.go new file mode 100644 index 00000000..1bfcff88 --- /dev/null +++ b/exercise7/blogging-platform/internal/api/handler/categories/update_category.go @@ -0,0 +1,52 @@ +package categories + +import ( + "database/sql" + "fmt" + "net/http" + "strconv" + + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/internal/db/blog" + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/pkg/httputils/request" + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/pkg/httputils/response" +) + +func (c *Categories) UpdateCategory(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := c.logger.With("method", "UpdateCategory") + + idString := r.URL.Path[len("/categories/"):] + id, err := strconv.Atoi(idString) + if err != nil { + log.ErrorContext(ctx, "invalid category ID", "error", err) + http.Error(w, "Invalid category ID", http.StatusBadRequest) + return + } + + var cat blog.CategoryRequest + if err := request.JSON(w, r, &cat); err != nil { + log.ErrorContext(ctx, "invalid request body", "error", err) + http.Error(w, fmt.Sprintf("Invalid request body: %v", err), http.StatusBadRequest) + return + } + + category, err := c.db.UpdateCategory(ctx, id, cat) + if err != nil { + if err == sql.ErrNoRows { + log.ErrorContext(ctx, "category not found", "category_id", id) + http.Error(w, "404 Not Found", http.StatusNotFound) + return + } + log.ErrorContext(ctx, "failed to update category", "error", err) + http.Error(w, fmt.Sprintf("Failed to update category: %s", err.Error()), http.StatusInternalServerError) + return + } + + if err := response.JSON(w, http.StatusCreated, category); err != nil { + log.ErrorContext(ctx, "failed to encode response", "error", err) + http.Error(w, fmt.Sprintf("Failed to encode response: %v", err), http.StatusInternalServerError) + return + } + + log.InfoContext(ctx, "success update category", "category", category.String()) +} 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..04312ea5 --- /dev/null +++ b/exercise7/blogging-platform/internal/api/handler/main.go @@ -0,0 +1,24 @@ +package handler + +import ( + "log/slog" + + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/internal/api/handler/categories" + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/internal/api/handler/posts" + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/internal/api/handler/tags" + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/internal/db" +) + +type Handler struct { + *categories.Categories + *posts.Posts + *tags.Tags +} + +func New(logger *slog.Logger, db *db.DB) *Handler { + return &Handler{ + Categories: categories.New(logger, db), + Posts: posts.New(logger, db), + Tags: tags.New(logger, db), + } +} diff --git a/exercise7/blogging-platform/internal/api/handler/ping.go b/exercise7/blogging-platform/internal/api/handler/ping.go new file mode 100644 index 00000000..9c7b3efd --- /dev/null +++ b/exercise7/blogging-platform/internal/api/handler/ping.go @@ -0,0 +1,16 @@ +package handler + +import ( + "log/slog" + "net/http" +) + +func (h *Handler) Ping(w http.ResponseWriter, r *http.Request) { + slog.Info("Received Ping request") + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte("pong")) + + if err != nil { + slog.Error("failed to write response: %v", err) + } +} diff --git a/exercise7/blogging-platform/internal/api/handler/posts/all_posts.go b/exercise7/blogging-platform/internal/api/handler/posts/all_posts.go new file mode 100644 index 00000000..c7b1b24c --- /dev/null +++ b/exercise7/blogging-platform/internal/api/handler/posts/all_posts.go @@ -0,0 +1,55 @@ +package posts + +import ( + "fmt" + "net/http" + + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/internal/db/blog" + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/pkg/httputils/response" +) + +func (p *Posts) GetAllPosts(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := p.logger.With("method", "GetAllPosts") + perm := r.URL.Query().Get("term") + var posts []blog.Post + var err error + + if perm != "" { + + log := p.logger.With("method", "SearchPosts") + + posts, err = p.db.SearchPosts(ctx, perm) + if err != nil { + log.ErrorContext(ctx, "failed to search posts", "error", err) + http.Error(w, fmt.Sprintf("Failed to search posts: %s", err.Error()), http.StatusInternalServerError) + return + } + + } else { + + posts, err = p.db.GetAllPosts(ctx) + if err != nil { + log.ErrorContext(ctx, "failed to get posts", "error", err) + http.Error(w, fmt.Sprintf("Failed to get posts: %s", err.Error()), http.StatusInternalServerError) + return + } + + } + + if len(posts) == 0 { + log.InfoContext(ctx, "no posts found") + http.Error(w, "404 Not Found", http.StatusNotFound) + return + } + + if err := response.JSON(w, http.StatusOK, posts); err != nil { + log.ErrorContext(ctx, "failed to encode response", "error", err) + http.Error(w, fmt.Sprintf("Failed to encode response: %v", err), http.StatusInternalServerError) + return + } + + log.InfoContext( + ctx, "success get all posts", + "number of posts", len(posts)) +} diff --git a/exercise7/blogging-platform/internal/api/handler/posts/delete_post.go b/exercise7/blogging-platform/internal/api/handler/posts/delete_post.go new file mode 100644 index 00000000..89dc08bf --- /dev/null +++ b/exercise7/blogging-platform/internal/api/handler/posts/delete_post.go @@ -0,0 +1,35 @@ +package posts + +import ( + "fmt" + "net/http" + "strconv" +) + +func (p *Posts) DeletePost(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := p.logger.With("method", "DeletePost") + + idString := r.URL.Path[len("/posts/"):] + id, err := strconv.Atoi(idString) + if err != nil { + log.ErrorContext(ctx, "invalid post ID", "error", err) + http.Error(w, "Invalid post ID", http.StatusBadRequest) + return + } + + err = p.db.DeletePost(ctx, id) + if err != nil { + if err.Error() == fmt.Sprintf("post with id %d not found", id) { + log.ErrorContext(ctx, "post not found", "category_id", id) + http.Error(w, "404 Not Found", http.StatusNotFound) + } else { + log.ErrorContext(ctx, "failed to delete post", "error", err) + http.Error(w, fmt.Sprintf("Failed to delete post: %s", err.Error()), http.StatusInternalServerError) + } + return + } + + log.InfoContext(ctx, "success delete post", "post_id", id) + w.WriteHeader(http.StatusNoContent) +} diff --git a/exercise7/blogging-platform/internal/api/handler/posts/info_post.go b/exercise7/blogging-platform/internal/api/handler/posts/info_post.go new file mode 100644 index 00000000..d65ef985 --- /dev/null +++ b/exercise7/blogging-platform/internal/api/handler/posts/info_post.go @@ -0,0 +1,39 @@ +package posts + +import ( + "fmt" + "net/http" + "strconv" + + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/pkg/httputils/response" +) + +func (p *Posts) GetInformationOfPost(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := p.logger.With("method", "GetInformationOfPost") + + idString := r.URL.Path[len("/posts/"):] + id, err := strconv.Atoi(idString) + if err != nil { + log.ErrorContext(ctx, "invalid post ID", "error", err) + http.Error(w, "Invalid post ID", http.StatusBadRequest) + return + } + + post, err := p.db.GetInformationOfPost(ctx, id) + if err != nil { + log.ErrorContext(ctx, "post not found", "post_id", id) + http.Error(w, "404 Not Found", http.StatusNotFound) + return + } + + if err := response.JSON(w, http.StatusOK, post); err != nil { + log.ErrorContext(ctx, "failed to encode response", "error", err) + http.Error(w, fmt.Sprintf("Failed to encode response: %v", err), http.StatusInternalServerError) + return + } + + log.InfoContext( + ctx, "success get information of post", + "post_id", id) +} diff --git a/exercise7/blogging-platform/internal/api/handler/posts/insert_post.go b/exercise7/blogging-platform/internal/api/handler/posts/insert_post.go new file mode 100644 index 00000000..c9037f40 --- /dev/null +++ b/exercise7/blogging-platform/internal/api/handler/posts/insert_post.go @@ -0,0 +1,41 @@ +package posts + +import ( + "fmt" + "net/http" + + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/internal/db/blog" + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/pkg/httputils/request" + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/pkg/httputils/response" +) + +func (p *Posts) InsertPost(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := p.logger.With("method", "InsertPost") + var req blog.PostRequest + + if err := request.JSON(w, r, &req); err != nil { + log.ErrorContext(ctx, "invalid request body", "error", err) + http.Error(w, fmt.Sprintf("Invalid request body: %v", err), http.StatusBadRequest) + return + } + + post, err := p.db.InsertPost(ctx, req) + if err != nil { + log.ErrorContext(ctx, "failed to create post", "error", err) + http.Error(w, fmt.Sprintf("Failed to create post: %v", err), http.StatusInternalServerError) + return + } + + if err := response.JSON(w, http.StatusCreated, post); err != nil { + log.ErrorContext(ctx, "failed to encode response", "error", err) + http.Error(w, fmt.Sprintf("Failed to encode response: %v", err), http.StatusInternalServerError) + return + } + + log.InfoContext( + ctx, + "success create post", + "post", post.String(), + ) +} diff --git a/exercise7/blogging-platform/internal/api/handler/posts/main.go b/exercise7/blogging-platform/internal/api/handler/posts/main.go new file mode 100644 index 00000000..aa27223f --- /dev/null +++ b/exercise7/blogging-platform/internal/api/handler/posts/main.go @@ -0,0 +1,19 @@ +package posts + +import ( + "log/slog" + + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/internal/db" +) + +type Posts struct { + logger *slog.Logger + db *db.DB +} + +func New(logger *slog.Logger, db *db.DB) *Posts { + return &Posts{ + logger: logger, + db: db, + } +} diff --git a/exercise7/blogging-platform/internal/api/handler/posts/update_post.go b/exercise7/blogging-platform/internal/api/handler/posts/update_post.go new file mode 100644 index 00000000..dae945ab --- /dev/null +++ b/exercise7/blogging-platform/internal/api/handler/posts/update_post.go @@ -0,0 +1,56 @@ +package posts + +import ( + "database/sql" + "fmt" + "net/http" + "strconv" + + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/internal/db/blog" + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/pkg/httputils/request" + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/pkg/httputils/response" +) + +func (p *Posts) UpdatePost(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := p.logger.With("method", "UpdatePost") + + idString := r.URL.Path[len("/posts/"):] + id_post, err := strconv.Atoi(idString) + if err != nil { + log.ErrorContext(ctx, "invalid post ID", "error", err) + http.Error(w, "Invalid post ID", http.StatusBadRequest) + return + } + + var req blog.PostRequest + if err := request.JSON(w, r, &req); err != nil { + log.ErrorContext(ctx, "invalid request body", "error", err) + http.Error(w, fmt.Sprintf("Invalid request body: %v", err), http.StatusBadRequest) + return + } + + post, err := p.db.UpdatePost(ctx, id_post, req) + if err != nil { + if err == sql.ErrNoRows { + log.ErrorContext(ctx, "post not found", "post_id", id_post) + http.Error(w, "404 Not Found", http.StatusNotFound) + return + } + log.ErrorContext(ctx, "failed to update post", "error", err) + http.Error(w, fmt.Sprintf("Failed to update post: %v", err), http.StatusInternalServerError) + return + } + + if err := response.JSON(w, http.StatusCreated, post); err != nil { + log.ErrorContext(ctx, "failed to encode response", "error", err) + http.Error(w, fmt.Sprintf("Failed to encode response: %v", err), http.StatusInternalServerError) + return + } + + log.InfoContext( + ctx, + "success update post", + "post", post.String(), + ) +} diff --git a/exercise7/blogging-platform/internal/api/handler/tags/all_posts of tags.go b/exercise7/blogging-platform/internal/api/handler/tags/all_posts of tags.go new file mode 100644 index 00000000..7e851e7c --- /dev/null +++ b/exercise7/blogging-platform/internal/api/handler/tags/all_posts of tags.go @@ -0,0 +1,46 @@ +package tags + +import ( + "fmt" + "net/http" + "strconv" + + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/pkg/httputils/response" +) + +func (t *Tags) GetAllPostsOfTag(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := t.logger.With("method", "GetAllPostsOfTag") + + idString := r.URL.Path[len("/tags/"):] + idString = idString[:len(idString)-6] + id, err := strconv.Atoi(idString) + if err != nil { + log.ErrorContext(ctx, "invalid tag ID", "error", err) + http.Error(w, "Invalid tag ID", http.StatusBadRequest) + return + } + posts, err := t.db.GetAllPostsOfTag(ctx, id) + if err != nil { + log.ErrorContext(ctx, "failed to get posts by tag", "error", err) + http.Error(w, fmt.Sprintf("Failed to get posts: %s", err.Error()), http.StatusInternalServerError) + return + } + + if len(posts) == 0 { + log.InfoContext(ctx, "no posts found for tag", "tag_id", id) + http.Error(w, "404 Not Found", http.StatusNotFound) + return + } + + if err := response.JSON(w, http.StatusOK, posts); err != nil { + log.ErrorContext(ctx, "failed to encode response", "error", err) + http.Error(w, fmt.Sprintf("Failed to encode response: %v", err), http.StatusInternalServerError) + return + } + + log.InfoContext( + ctx, "success get all posts of tag", + "tag_id", id, + "number of posts", len(posts)) +} diff --git a/exercise7/blogging-platform/internal/api/handler/tags/all_tags.go b/exercise7/blogging-platform/internal/api/handler/tags/all_tags.go new file mode 100644 index 00000000..471d30a6 --- /dev/null +++ b/exercise7/blogging-platform/internal/api/handler/tags/all_tags.go @@ -0,0 +1,35 @@ +package tags + +import ( + "fmt" + "net/http" + + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/pkg/httputils/response" +) + +func (t *Tags) GetAllTags(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := t.logger.With("method", "GetAllTags") + + tags, err := t.db.GetAllTags(ctx) + if err != nil { + log.ErrorContext(ctx, "failed to get tags", "error", err) + http.Error(w, fmt.Sprintf("Failed to get tags: %s", err.Error()), http.StatusInternalServerError) + return + } + + if len(tags) == 0 { + log.InfoContext(ctx, "no tags found") + http.Error(w, "404 Not Found", http.StatusNotFound) + return + } + + if err := response.JSON(w, http.StatusOK, tags); err != nil { + log.ErrorContext(ctx, "failed to encode response", "error", err) + http.Error(w, fmt.Sprintf("Failed to encode response: %v", err), http.StatusInternalServerError) + return + } + + log.InfoContext(ctx, "success get all tags", "number of tags", len(tags)) + +} diff --git a/exercise7/blogging-platform/internal/api/handler/tags/main.go b/exercise7/blogging-platform/internal/api/handler/tags/main.go new file mode 100644 index 00000000..6f218cb8 --- /dev/null +++ b/exercise7/blogging-platform/internal/api/handler/tags/main.go @@ -0,0 +1,19 @@ +package tags + +import ( + "log/slog" + + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/internal/db" +) + +type Tags struct { + logger *slog.Logger + db *db.DB +} + +func New(logger *slog.Logger, db *db.DB) *Tags { + return &Tags{ + logger: logger, + db: 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..e06ac3c1 --- /dev/null +++ b/exercise7/blogging-platform/internal/api/main.go @@ -0,0 +1,73 @@ +package api + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + "os" + "strconv" + + "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" +) + +type Api struct { + logger *slog.Logger + router *router.Router + server *http.Server +} + +func New(logger *slog.Logger, db *db.DB) *Api { + h := handler.New(logger, db) + r := router.New(h) + + return &Api{ + logger: logger, + router: r, + } +} + +func (api *Api) Start(ctx context.Context) error { + mux := api.router.Start(ctx) + + port, err := strconv.Atoi(os.Getenv("API_PORT")) + if err != nil { + return err + } + + srv := &http.Server{ + Addr: fmt.Sprintf(":%d", port), + Handler: mux, + BaseContext: func(_ net.Listener) context.Context { + return ctx + }, + } + + api.server = srv + + slog.InfoContext( + ctx, + "starting service", + "port", port, + ) + + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + slog.ErrorContext(ctx, "service error", "error", err) + return err + } + + return nil +} + +func (api *Api) Stop(ctx context.Context) error { + if err := api.server.Shutdown(ctx); err != nil { + slog.ErrorContext(ctx, "server shutdown error", "error", err) + return err + } + + return nil +} diff --git a/exercise7/blogging-platform/internal/api/router/categories.go b/exercise7/blogging-platform/internal/api/router/categories.go new file mode 100644 index 00000000..865d0849 --- /dev/null +++ b/exercise7/blogging-platform/internal/api/router/categories.go @@ -0,0 +1,15 @@ +package router + +import ( + "context" +) + +func (r *Router) categories(ctx context.Context) { + r.router.HandleFunc("POST /categories", r.handler.InsertCategory) + r.router.HandleFunc("DELETE /categories/{id}", r.handler.DeleteCategory) + r.router.HandleFunc("PUT /categories/{id}", r.handler.UpdateCategory) + + r.router.HandleFunc("GET /categories", r.handler.GetAllCategories) + r.router.HandleFunc("GET /categories/{id}", r.handler.GetInformationOfCategory) + r.router.HandleFunc("GET /categories/{id}/posts", r.handler.GetAllPostsOfCategory) +} diff --git a/exercise7/blogging-platform/internal/api/router/posts.go b/exercise7/blogging-platform/internal/api/router/posts.go new file mode 100644 index 00000000..c1954955 --- /dev/null +++ b/exercise7/blogging-platform/internal/api/router/posts.go @@ -0,0 +1,13 @@ +package router + +import ( + "context" +) + +func (r *Router) posts(ctx context.Context) { + r.router.HandleFunc("POST /posts", r.handler.InsertPost) + r.router.HandleFunc("DELETE /posts/{id}", r.handler.DeletePost) + r.router.HandleFunc("PUT /posts/{id}", r.handler.UpdatePost) + r.router.HandleFunc("GET /posts/{id}", r.handler.GetInformationOfPost) + r.router.HandleFunc("GET /posts", r.handler.GetAllPosts) +} diff --git a/exercise7/blogging-platform/internal/api/router/router.go b/exercise7/blogging-platform/internal/api/router/router.go new file mode 100644 index 00000000..6886a78c --- /dev/null +++ b/exercise7/blogging-platform/internal/api/router/router.go @@ -0,0 +1,30 @@ +package router + +import ( + "context" + "net/http" + + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/internal/api/handler" +) + +type Router struct { + router *http.ServeMux + handler *handler.Handler +} + +func New(handler *handler.Handler) *Router { + mux := http.NewServeMux() + + return &Router{ + router: mux, + handler: handler, + } +} + +func (r *Router) Start(ctx context.Context) *http.ServeMux { + r.server(ctx) + r.categories(ctx) + r.posts(ctx) + r.tags(ctx) + return r.router +} diff --git a/exercise7/blogging-platform/internal/api/router/server.go b/exercise7/blogging-platform/internal/api/router/server.go new file mode 100644 index 00000000..b4fa6747 --- /dev/null +++ b/exercise7/blogging-platform/internal/api/router/server.go @@ -0,0 +1,9 @@ +package router + +import ( + "context" +) + +func (r *Router) server(ctx context.Context) { + r.router.HandleFunc("GET /ping", r.handler.Ping) +} diff --git a/exercise7/blogging-platform/internal/api/router/tags.go b/exercise7/blogging-platform/internal/api/router/tags.go new file mode 100644 index 00000000..7112d5f1 --- /dev/null +++ b/exercise7/blogging-platform/internal/api/router/tags.go @@ -0,0 +1,10 @@ +package router + +import ( + "context" +) + +func (r *Router) tags(ctx context.Context) { + r.router.HandleFunc("GET /tags", r.handler.GetAllTags) + r.router.HandleFunc("GET /tags/{id}/posts", r.handler.GetAllPostsOfTag) +} 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..1959bfbc --- /dev/null +++ b/exercise7/blogging-platform/internal/db/blog/main.go @@ -0,0 +1,103 @@ +package blog + +import ( + "encoding/json" + "fmt" + "time" +) + +type Category struct { + ID int `json:"id"` + Name string `json:"name"` + Posts []*Post `json:"posts,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +type CategoryRequest struct { + Name string `json:"name"` +} + +func (c *Category) MarshalJSON() ([]byte, error) { + type Alias Category + return json.Marshal(&struct { + *Alias + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + }{ + Alias: (*Alias)(c), + CreatedAt: filterNullTime(c.CreatedAt), + UpdatedAt: filterNullTime(c.UpdatedAt), + }) +} + +func filterNullTime(t *time.Time) *time.Time { + if t == nil || t.IsZero() { + return nil + } + return t +} + +type Tag struct { + ID int `json:"id"` + Name string `json:"name"` + Posts []*Post `json:"posts,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +func (t *Tag) MarshalJSON() ([]byte, error) { + type Alias Tag + return json.Marshal(&struct { + *Alias + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + }{ + Alias: (*Alias)(t), + CreatedAt: filterNullTime(t.CreatedAt), + UpdatedAt: filterNullTime(t.UpdatedAt), + }) +} + +type Post struct { + ID int `json:"id"` + Title string `json:"title"` + Content string `json:"content"` + Category *Category `json:"category"` + Tags []*Tag `json:"tags,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +func (p *Post) MarshalJSON() ([]byte, error) { + type Alias Post + return json.Marshal(&struct { + *Alias + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + }{ + Alias: (*Alias)(p), + CreatedAt: filterNullTime(p.CreatedAt), + UpdatedAt: filterNullTime(p.UpdatedAt), + }) +} + +type PostRequest struct { + Title string `json:"title"` + Content string `json:"content"` + Category string `json:"category"` + Tags []string `json:"tags"` +} + +// для правильного вывода логов +func (c *Category) String() string { + return fmt.Sprintf("Category: {ID: %d, Name: %s }", c.ID, c.Name) +} + +func (t *Tag) String() string { + return fmt.Sprintf("Tag: {ID: %d, Name: %s}", t.ID, t.Name) +} + +func (p *Post) String() string { + return fmt.Sprintf("Post:{ID: %d, Title: %s, Content: %s, %s, Tags: %v}", p.ID, p.Title, p.Content, p.Category.String(), p.Tags) +} diff --git a/exercise7/blogging-platform/internal/db/category/all_categories.go b/exercise7/blogging-platform/internal/db/category/all_categories.go new file mode 100644 index 00000000..ff8427c7 --- /dev/null +++ b/exercise7/blogging-platform/internal/db/category/all_categories.go @@ -0,0 +1,38 @@ +package category + +import ( + "context" + + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/internal/db/blog" +) + +func (c *Category) GetAllCategories(ctx context.Context) ([]blog.Category, error) { + log := c.logger.With("method", "GetAllCategories") + + query := `SELECT id, name, created_at, updated_at FROM category` + rows, err := c.db.QueryContext(ctx, query) + if err != nil { + log.ErrorContext(ctx, "fail to query categories", "error", err) + return nil, err + } + + defer rows.Close() + + var categories []blog.Category + for rows.Next() { + var cat blog.Category + if err := rows.Scan(&cat.ID, &cat.Name, &cat.CreatedAt, &cat.UpdatedAt); err != nil { + log.ErrorContext(ctx, "fail to scan category", "error", err) + return nil, err + } + categories = append(categories, cat) + } + + if err := rows.Err(); err != nil { + log.ErrorContext(ctx, "fail to scan rows", "error", err) + return nil, err + } + + log.InfoContext(ctx, "success get all categories") + return categories, nil +} diff --git a/exercise7/blogging-platform/internal/db/category/all_posts_of_category.go b/exercise7/blogging-platform/internal/db/category/all_posts_of_category.go new file mode 100644 index 00000000..ae970bd0 --- /dev/null +++ b/exercise7/blogging-platform/internal/db/category/all_posts_of_category.go @@ -0,0 +1,108 @@ +package category + +import ( + "context" + "database/sql" + "sort" + "strconv" + "strings" + + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/internal/db/blog" +) + +func (c *Category) GetAllPostsOfCategory(ctx context.Context, id int) ([]blog.Post, error) { + log := c.logger.With("method", "GetAllPostsOfCategory") + + query := ` + SELECT + p.id, + p.title, + p.content, + p.created_at, + p.updated_at, + c.id AS category_id, + c.name AS category_name, + STRING_AGG(t.id::TEXT, ',') AS tag_ids, + STRING_AGG(t.name, ',') AS tag_names + FROM + post p + JOIN category c ON p.id_category = c.id + LEFT JOIN post_tags pt ON p.id = pt.id_post + LEFT JOIN tag t ON pt.id_tag = t.id + WHERE c.id = $1 + GROUP BY p.id, c.id + ORDER BY p.id; + ` + + rows, err := c.db.QueryContext(ctx, query, id) + if err != nil { + log.ErrorContext(ctx, "fail to query posts", "error", err) + return nil, err + } + defer rows.Close() + + var posts []blog.Post + + for rows.Next() { + var post blog.Post + var category blog.Category + var tagIDs, tagNames sql.NullString + + err := rows.Scan( + &post.ID, + &post.Title, + &post.Content, + &post.CreatedAt, + &post.UpdatedAt, + &category.ID, + &category.Name, + &tagIDs, + &tagNames, + ) + if err != nil { + log.ErrorContext(ctx, "fail to scan posts", "error", err) + return nil, err + } + + post.Category = &category + + if tagIDs.Valid && tagIDs.String != "" { + tagIDStrings := strings.Split(tagIDs.String, ",") + for _, idStr := range tagIDStrings { + tagID, err := strconv.Atoi(idStr) + if err == nil { + post.Tags = append(post.Tags, &blog.Tag{ + ID: tagID, + }) + } + } + } + + if tagNames.Valid && tagNames.String != "" { + tagNameStrings := strings.Split(tagNames.String, ",") + + for i, name := range tagNameStrings { + if i < len(post.Tags) { + post.Tags[i].Name = name + } else { + post.Tags = append(post.Tags, &blog.Tag{ + Name: name, + }) + } + } + } + sort.Slice(post.Tags, func(i, j int) bool { + return post.Tags[i].ID < post.Tags[j].ID + }) + + posts = append(posts, post) + } + + if err := rows.Err(); err != nil { + log.ErrorContext(ctx, "fail to scan rows", "error", err) + return nil, err + } + + log.InfoContext(ctx, "success get all posts of category") + return posts, nil +} diff --git a/exercise7/blogging-platform/internal/db/category/delete_category.go b/exercise7/blogging-platform/internal/db/category/delete_category.go new file mode 100644 index 00000000..c46e98dd --- /dev/null +++ b/exercise7/blogging-platform/internal/db/category/delete_category.go @@ -0,0 +1,32 @@ +package category + +import ( + "context" + "fmt" +) + +func (c *Category) DeleteCategory(ctx context.Context, id int) error { + log := c.logger.With("method", "DeleteCategory") + + queryDeleteCategory := `DELETE FROM category WHERE id = $1` + deleteCategory, err := c.db.ExecContext(ctx, queryDeleteCategory, id) + if err != nil { + log.ErrorContext(ctx, "failed to delete category", "error", err) + return err + } + + countCategory, err := deleteCategory.RowsAffected() + if err != nil { + log.ErrorContext(ctx, "failed to get rows affected", "error", err) + return err + } + + if countCategory == 0 { + log.ErrorContext(ctx, "category not found", "category_id", id) + return fmt.Errorf("category with id %d not found", id) + } + + log.InfoContext(ctx, "success delete category") + + return nil +} diff --git a/exercise7/blogging-platform/internal/db/category/info_category.go b/exercise7/blogging-platform/internal/db/category/info_category.go new file mode 100644 index 00000000..3f3251fa --- /dev/null +++ b/exercise7/blogging-platform/internal/db/category/info_category.go @@ -0,0 +1,27 @@ +package category + +import ( + "context" + "database/sql" + "fmt" + + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/internal/db/blog" +) + +func (c *Category) GetInformationOfCategory(ctx context.Context, id int) (*blog.Category, error) { + log := c.logger.With("method", "GetInformationOfCategory") + + var category blog.Category + query := `SELECT id, name, created_at, updated_at FROM category WHERE id = $1` + row := c.db.QueryRowContext(ctx, query, id) + err := row.Scan(&category.ID, &category.Name, &category.CreatedAt, &category.UpdatedAt) + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("category with id %d not found", id) + } + return nil, err + } + + log.InfoContext(ctx, "success get information of category") + return &category, nil +} diff --git a/exercise7/blogging-platform/internal/db/category/insert_category.go b/exercise7/blogging-platform/internal/db/category/insert_category.go new file mode 100644 index 00000000..2416b8bf --- /dev/null +++ b/exercise7/blogging-platform/internal/db/category/insert_category.go @@ -0,0 +1,22 @@ +package category + +import ( + "context" + + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/internal/db/blog" +) + +func (c *Category) InsertCategory(ctx context.Context, cat blog.CategoryRequest) (*blog.Category, error) { + log := c.logger.With("method", "InsertCategory") + + var category blog.Category + query := `INSERT INTO category (name) VALUES ($1) RETURNING id, name, created_at, updated_at` + err := c.db.QueryRowContext(ctx, query, cat.Name).Scan(&category.ID, &category.Name, &category.CreatedAt, &category.UpdatedAt) + if err != nil { + log.ErrorContext(ctx, "fail to scan rows", "error", err) + return nil, err + } + + log.InfoContext(ctx, "success insert new category") + return &category, nil +} diff --git a/exercise7/blogging-platform/internal/db/category/main.go b/exercise7/blogging-platform/internal/db/category/main.go new file mode 100644 index 00000000..440c191a --- /dev/null +++ b/exercise7/blogging-platform/internal/db/category/main.go @@ -0,0 +1,18 @@ +package category + +import ( + "database/sql" + "log/slog" +) + +type Category struct { + logger *slog.Logger + db *sql.DB +} + +func New(db *sql.DB, logger *slog.Logger) *Category { + return &Category{ + logger: logger, + db: db, + } +} diff --git a/exercise7/blogging-platform/internal/db/category/update_category.go b/exercise7/blogging-platform/internal/db/category/update_category.go new file mode 100644 index 00000000..57aa59c5 --- /dev/null +++ b/exercise7/blogging-platform/internal/db/category/update_category.go @@ -0,0 +1,32 @@ +package category + +import ( + "context" + "database/sql" + "errors" + + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/internal/db/blog" +) + +func (c *Category) UpdateCategory(ctx context.Context, id int, cat blog.CategoryRequest) (*blog.Category, error) { + log := c.logger.With("method", "UpdateCategory") + + if cat.Name == "" { + log.ErrorContext(ctx, "category name cannot be empty") + return nil, errors.New("category name cannot be empty") + } + + var category blog.Category + query := `UPDATE category SET name = $1, updated_at = NOW() WHERE id = $2 RETURNING id, name, created_at, updated_at` + err := c.db.QueryRowContext(ctx, query, cat.Name, id).Scan(&category.ID, &category.Name, &category.CreatedAt, &category.UpdatedAt) + if err != nil { + if err == sql.ErrNoRows { + return nil, sql.ErrNoRows + } + log.ErrorContext(ctx, "failed to update category", "error", err) + return nil, err + } + + log.InfoContext(ctx, "success update category") + return &category, nil +} diff --git a/exercise7/blogging-platform/internal/db/init.go b/exercise7/blogging-platform/internal/db/init.go new file mode 100644 index 00000000..905caad7 --- /dev/null +++ b/exercise7/blogging-platform/internal/db/init.go @@ -0,0 +1,108 @@ +package db + +import ( + "context" +) + +func (db *DB) Init(ctx context.Context) error { + log := db.logger.With("method", "Init") + + if err := db.InitCategory(ctx); err != nil { + return err + } + + if err := db.InitTag(ctx); err != nil { + return err + } + + if err := db.InitPost(ctx); err != nil { + return err + } + + if err := db.InitPostTags(ctx); err != nil { + return err + } + + log.InfoContext(ctx, "success create tables for blogging system") + return nil +} + +func (db *DB) InitCategory(ctx context.Context) error { + log := db.logger.With("table", "category") + stmt := ` + CREATE TABLE IF NOT EXISTS category + ( + id serial PRIMARY KEY, + name VARCHAR(255) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + );` + + if _, err := db.pg.Exec(stmt); err != nil { + log.ErrorContext(ctx, "failed to create category table", "error", err) + return err + } + + return nil +} + +func (db *DB) InitTag(ctx context.Context) error { + log := db.logger.With("table", "tag") + stmt := ` + CREATE TABLE IF NOT EXISTS tag + ( + id serial PRIMARY KEY, + name VARCHAR(255) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + );` + + if _, err := db.pg.Exec(stmt); err != nil { + log.ErrorContext(ctx, "failed to create tag table", "error", err) + return err + } + + return nil +} + +func (db *DB) InitPost(ctx context.Context) error { + log := db.logger.With("table", "post") + stmt := ` + CREATE TABLE IF NOT EXISTS post + ( + id serial PRIMARY KEY, + id_category int references category (id) on delete cascade not null, + title VARCHAR(255) NOT NULL, + content TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + );` + + if _, err := db.pg.Exec(stmt); err != nil { + log.ErrorContext(ctx, "failed to create post table", "error", err) + return err + } + + return nil +} + +func (db *DB) InitPostTags(ctx context.Context) error { + log := db.logger.With("table", "post_tags") + + stmt := ` + CREATE TABLE IF NOT EXISTS post_tags + ( + id serial PRIMARY KEY, + id_post int references post (id) on delete cascade not null, + id_tag int references tag (id) on delete cascade not null, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + );` + + if _, err := db.pg.Exec(stmt); err != nil { + log.ErrorContext(ctx, "failed to create post_tags table", "error", err) + return err + } + + return nil +} diff --git a/exercise7/blogging-platform/internal/db/main.go b/exercise7/blogging-platform/internal/db/main.go new file mode 100644 index 00000000..d50e3518 --- /dev/null +++ b/exercise7/blogging-platform/internal/db/main.go @@ -0,0 +1,73 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + "log/slog" + "os" + "strconv" + + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/internal/db/category" + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/internal/db/post" + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/internal/db/tag" + + _ "github.com/lib/pq" +) + +type DB struct { + logger *slog.Logger + pg *sql.DB + *category.Category + *post.Post + *tag.Tag +} + +func New(logger *slog.Logger) (*DB, error) { + pgsql, err := newPgSQL() + if err != nil { + return nil, err + } + + return &DB{ + logger: logger, + pg: pgsql, + Category: category.New(pgsql, logger), + Post: post.New(pgsql, logger), + Tag: tag.New(pgsql, logger), + }, 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") + + 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 + } + + return db, nil +} + +func (db *DB) Ping(ctx context.Context) error { + err := db.pg.PingContext(ctx) + if err != nil { + db.logger.ErrorContext(ctx, "failed to connect to database", "error", err) + return err + } + + db.logger.InfoContext(ctx, "success connected to database") + return nil +} diff --git a/exercise7/blogging-platform/internal/db/post/all_posts.go b/exercise7/blogging-platform/internal/db/post/all_posts.go new file mode 100644 index 00000000..ef7fe80d --- /dev/null +++ b/exercise7/blogging-platform/internal/db/post/all_posts.go @@ -0,0 +1,107 @@ +package post + +import ( + "context" + "database/sql" + "sort" + "strconv" + "strings" + + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/internal/db/blog" +) + +func (p *Post) GetAllPosts(ctx context.Context) ([]blog.Post, error) { + log := p.logger.With("method", "GetAllPosts") + + query := ` + SELECT + p.id, + p.title, + p.content, + p.created_at, + p.updated_at, + c.id AS category_id, + c.name AS category_name, + STRING_AGG(t.id::TEXT, ',') AS tag_ids, + STRING_AGG(t.name, ',') AS tag_names + FROM + post p + JOIN category c ON p.id_category = c.id + LEFT JOIN post_tags pt ON p.id = pt.id_post + LEFT JOIN tag t ON pt.id_tag = t.id + GROUP BY p.id, c.id + ORDER BY p.id; + ` + + rows, err := p.db.QueryContext(ctx, query) + if err != nil { + log.ErrorContext(ctx, "fail to query posts", "error", err) + return nil, err + } + defer rows.Close() + + var posts []blog.Post + + for rows.Next() { + var post blog.Post + var category blog.Category + var tagIDs, tagNames sql.NullString + + err := rows.Scan( + &post.ID, + &post.Title, + &post.Content, + &post.CreatedAt, + &post.UpdatedAt, + &category.ID, + &category.Name, + &tagIDs, + &tagNames, + ) + if err != nil { + log.ErrorContext(ctx, "fail to scan posts", "error", err) + return nil, err + } + + post.Category = &category + + if tagIDs.Valid && tagIDs.String != "" { + tagIDStrings := strings.Split(tagIDs.String, ",") + for _, idStr := range tagIDStrings { + tagID, err := strconv.Atoi(idStr) + if err == nil { + post.Tags = append(post.Tags, &blog.Tag{ + ID: tagID, + }) + } + } + } + + if tagNames.Valid && tagNames.String != "" { + tagNameStrings := strings.Split(tagNames.String, ",") + + for i, name := range tagNameStrings { + if i < len(post.Tags) { + post.Tags[i].Name = name + } else { + post.Tags = append(post.Tags, &blog.Tag{ + Name: name, + }) + } + } + } + sort.Slice(post.Tags, func(i, j int) bool { + return post.Tags[i].ID < post.Tags[j].ID + }) + + posts = append(posts, post) + } + + if err := rows.Err(); err != nil { + log.ErrorContext(ctx, "fail to scan rows", "error", err) + return nil, err + } + + log.InfoContext(ctx, "success get all posts") + return posts, nil +} diff --git a/exercise7/blogging-platform/internal/db/post/delete_post.go b/exercise7/blogging-platform/internal/db/post/delete_post.go new file mode 100644 index 00000000..9f273a95 --- /dev/null +++ b/exercise7/blogging-platform/internal/db/post/delete_post.go @@ -0,0 +1,32 @@ +package post + +import ( + "context" + "fmt" +) + +func (p *Post) DeletePost(ctx context.Context, id int) error { + log := p.logger.With("method", "DeletePost") + + queryDeletePost := `DELETE FROM post WHERE id = $1` + deletePost, err := p.db.ExecContext(ctx, queryDeletePost, id) + if err != nil { + log.ErrorContext(ctx, "failed to delete post", "error", err) + return err + } + + countPost, err := deletePost.RowsAffected() + if err != nil { + log.ErrorContext(ctx, "failed to get rows affected", "error", err) + return err + } + + if countPost == 0 { + log.ErrorContext(ctx, "post not found", "post_id", id) + return fmt.Errorf("post with id %d not found", id) + } + + log.InfoContext(ctx, "success delete post") + + return nil +} diff --git a/exercise7/blogging-platform/internal/db/post/info_post.go b/exercise7/blogging-platform/internal/db/post/info_post.go new file mode 100644 index 00000000..892fcdfc --- /dev/null +++ b/exercise7/blogging-platform/internal/db/post/info_post.go @@ -0,0 +1,69 @@ +package post + +import ( + "context" + "database/sql" + "fmt" + + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/internal/db/blog" +) + +func (p *Post) GetInformationOfPost(ctx context.Context, id int) (blog.Post, error) { + log := p.logger.With("method", "GetInformationOfPost") + + query := ` + SELECT p.id, p.title, p.content, p.created_at, p.updated_at, + c.id, c.name, + t.id, t.name + FROM post p + JOIN category c ON p.id_category = c.id + LEFT JOIN post_tags pt ON pt.id_post = p.id + LEFT JOIN tag t ON pt.id_tag = t.id + WHERE p.id = $1 + ` + + rows, err := p.db.QueryContext(ctx, query, id) + if err != nil { + log.ErrorContext(ctx, "fail to get post information", "error", err) + return blog.Post{}, err + } + defer rows.Close() + + var post blog.Post + var category blog.Category + var tags []*blog.Tag + postFound := false + for rows.Next() { + var tagID sql.NullInt64 + var tagName sql.NullString + + err := rows.Scan(&post.ID, &post.Title, &post.Content, &post.CreatedAt, &post.UpdatedAt, + &category.ID, &category.Name, &tagID, &tagName) + if err != nil { + log.ErrorContext(ctx, "failed to scan row", "error", err) + return blog.Post{}, err + } + + post.Category = &category + + if tagID.Valid && tagName.Valid { + tags = append(tags, &blog.Tag{ID: int(tagID.Int64), Name: tagName.String}) + } + + postFound = true + } + + if !postFound { + log.ErrorContext(ctx, "post not found", "post_id", id) + return blog.Post{}, fmt.Errorf("post with id %d not found", id) + } + + post.Tags = tags + + if len(tags) == 0 { + log.InfoContext(ctx, "no tags found for the post", "post_id", id) + } + + log.InfoContext(ctx, "success get information of post") + return post, nil +} diff --git a/exercise7/blogging-platform/internal/db/post/insert_post.go b/exercise7/blogging-platform/internal/db/post/insert_post.go new file mode 100644 index 00000000..a09fcedc --- /dev/null +++ b/exercise7/blogging-platform/internal/db/post/insert_post.go @@ -0,0 +1,157 @@ +package post + +import ( + "context" + "database/sql" + + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/internal/db/blog" +) + +func (p *Post) InsertPost(ctx context.Context, req blog.PostRequest) (*blog.Post, error) { + log := p.logger.With("method", "InsertPost") + + tx, err := p.db.BeginTx(ctx, nil) + if err != nil { + log.ErrorContext(ctx, "fail to begin transaction", "error", err) + return nil, err + } + defer tx.Rollback() + + id_category, err := p.GetCategoryIDByName(ctx, tx, req.Category) + if err != nil { + log.ErrorContext(ctx, "failed to get category by name", "error", err) + return nil, err + } + + category := &blog.Category{ + ID: id_category, + Name: req.Category, + } + + if id_category == 0 { + category, err = p.InsertCat(ctx, tx, *category) + if err != nil { + log.ErrorContext(ctx, "failed to create category", "error", err) + return nil, err + } + } + + id_category = category.ID + post, err := p.InsertInfoPost(ctx, tx, id_category, req) + if err != nil { + log.ErrorContext(ctx, "failed to create post", "error", err) + return nil, err + } + post.Category = category + + tags, err := p.InsertTags(ctx, tx, post.ID, req.Tags) + if err != nil { + log.ErrorContext(ctx, "failed to insert tags", "error", err) + return nil, err + } + post.Tags = tags + + if err := tx.Commit(); err != nil { + log.ErrorContext(ctx, "fail to commit transaction", "error", err) + return nil, err + } + + log.InfoContext(ctx, "success insert new post") + return post, nil +} + +func (p *Post) InsertInfoPost(ctx context.Context, tx *sql.Tx, id_category int, req blog.PostRequest) (*blog.Post, error) { + log := p.logger.With("method", "InsertPost") + var post blog.Post + query := `INSERT INTO post (id_category,title,"content") VALUES ($1, $2, $3) RETURNING id, title, content, created_at, updated_at` + err := tx.QueryRowContext(ctx, query, id_category, req.Title, req.Content).Scan(&post.ID, &post.Title, &post.Content, &post.CreatedAt, &post.UpdatedAt) + if err != nil { + log.ErrorContext(ctx, "fail to scan rows", "error", err) + return nil, err + } + + return &post, nil +} + +func (p *Post) GetCategoryIDByName(ctx context.Context, tx *sql.Tx, name string) (int, error) { + log := p.logger.With("method", "InsertPost") + query := `SELECT category.id FROM category WHERE name= $1` + var id int + err := tx.QueryRowContext(ctx, query, name).Scan(&id) + if err != nil { + if err == sql.ErrNoRows { + return 0, nil + } + log.ErrorContext(ctx, "failed to get category by name", "error", err) + return 0, err + } + + return id, nil +} + +func (p *Post) InsertCat(ctx context.Context, tx *sql.Tx, cat blog.Category) (*blog.Category, error) { + log := p.logger.With("method", "InsertPost") + var category blog.Category + query := `INSERT INTO category (name) VALUES ($1) RETURNING id, name, created_at, updated_at` + err := tx.QueryRowContext(ctx, query, cat.Name).Scan(&category.ID, &category.Name, &category.CreatedAt, &category.UpdatedAt) + if err != nil { + log.ErrorContext(ctx, "fail to scan rows", "error", err) + return nil, err + } + + log.InfoContext(ctx, "success insert new category") + return &category, nil +} + +func (p *Post) InsertTags(ctx context.Context, tx *sql.Tx, id_post int, tags []string) ([]*blog.Tag, error) { + log := p.logger.With("method", "InsertPost") + var insertedTags []*blog.Tag + + for _, tagName := range tags { + var tag blog.Tag + + err := tx.QueryRowContext(ctx, "SELECT id, name, created_at, updated_at FROM tag WHERE name = $1", tagName).Scan(&tag.ID, &tag.Name, &tag.CreatedAt, &tag.UpdatedAt) + + if err == sql.ErrNoRows { + + err := tx.QueryRowContext(ctx, "INSERT INTO tag(name) VALUES($1) RETURNING id, name, created_at, updated_at", tagName).Scan(&tag.ID, &tag.Name, &tag.CreatedAt, &tag.UpdatedAt) + if err != nil { + log.ErrorContext(ctx, "fail to insert tag", "error", err) + return nil, err + } + } else if err != nil { + log.ErrorContext(ctx, "fail to check if tag exists", "error", err) + return nil, err + } + + insertedTags = append(insertedTags, &tag) + + err = p.InsertPostTags(ctx, tx, tag.ID, id_post) + if err != nil { + log.ErrorContext(ctx, "fail to insert tag-post relation", "error", err) + return nil, err + } + } + + return insertedTags, nil +} + +func (p *Post) InsertPostTags(ctx context.Context, tx *sql.Tx, tagID, postID int) error { + log := p.logger.With("method", "InsertPost") + var count int + err := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM post_tags WHERE id_post = $1 AND id_tag = $2", postID, tagID).Scan(&count) + if err != nil { + log.ErrorContext(ctx, "fail to check if post-tag relation exists", "error", err) + return err + } + + if count == 0 { + _, err := tx.ExecContext(ctx, "INSERT INTO post_tags(id_post, id_tag) VALUES($1, $2)", postID, tagID) + if err != nil { + log.ErrorContext(ctx, "fail to insert post-tag relation", "error", err) + return err + } + } + + return nil +} diff --git a/exercise7/blogging-platform/internal/db/post/main.go b/exercise7/blogging-platform/internal/db/post/main.go new file mode 100644 index 00000000..5995966b --- /dev/null +++ b/exercise7/blogging-platform/internal/db/post/main.go @@ -0,0 +1,18 @@ +package post + +import ( + "database/sql" + "log/slog" +) + +type Post struct { + logger *slog.Logger + db *sql.DB +} + +func New(db *sql.DB, logger *slog.Logger) *Post { + return &Post{ + logger: logger, + db: db, + } +} diff --git a/exercise7/blogging-platform/internal/db/post/search_posts.go b/exercise7/blogging-platform/internal/db/post/search_posts.go new file mode 100644 index 00000000..cb14493b --- /dev/null +++ b/exercise7/blogging-platform/internal/db/post/search_posts.go @@ -0,0 +1,119 @@ +package post + +import ( + "context" + "database/sql" + "sort" + "strconv" + "strings" + + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/internal/db/blog" +) + +func (p *Post) SearchPosts(ctx context.Context, searchQuery string) ([]blog.Post, error) { + log := p.logger.With("method", "SearchPosts") + + query := ` + SELECT + p.id, + p.title, + p.content, + p.created_at, + p.updated_at, + c.id AS category_id, + c.name AS category_name, + STRING_AGG(t.id::TEXT, ',') AS tag_ids, + STRING_AGG(t.name, ',') AS tag_names + FROM + post p + JOIN category c ON p.id_category = c.id + LEFT JOIN post_tags pt ON p.id = pt.id_post + LEFT JOIN tag t ON pt.id_tag = t.id + WHERE + p.title ILIKE '%' || $1 || '%' OR + p.content ILIKE '%' || $2 || '%' OR + c.name ILIKE '%' || $3 || '%' OR + t.name ILIKE '%' || $4 || '%' + GROUP BY p.id, c.id + ORDER BY p.id; + ` + + args := []interface{}{ + "%" + searchQuery + "%", + "%" + searchQuery + "%", + "%" + searchQuery + "%", + "%" + searchQuery + "%", + } + + rows, err := p.db.QueryContext(ctx, query, args...) + if err != nil { + log.ErrorContext(ctx, "failed to search posts", "error", err) + return nil, err + } + defer rows.Close() + + var posts []blog.Post + + for rows.Next() { + var post blog.Post + var category blog.Category + var tagIDs, tagNames sql.NullString + + err := rows.Scan( + &post.ID, + &post.Title, + &post.Content, + &post.CreatedAt, + &post.UpdatedAt, + &category.ID, + &category.Name, + &tagIDs, + &tagNames, + ) + if err != nil { + log.ErrorContext(ctx, "fail to scan posts", "error", err) + return nil, err + } + + post.Category = &category + + if tagIDs.Valid && tagIDs.String != "" { + tagIDStrings := strings.Split(tagIDs.String, ",") + for _, idStr := range tagIDStrings { + tagID, err := strconv.Atoi(idStr) + if err == nil { + post.Tags = append(post.Tags, &blog.Tag{ + ID: tagID, + }) + } + } + } + + if tagNames.Valid && tagNames.String != "" { + tagNameStrings := strings.Split(tagNames.String, ",") + + for i, name := range tagNameStrings { + if i < len(post.Tags) { + post.Tags[i].Name = name + } else { + post.Tags = append(post.Tags, &blog.Tag{ + Name: name, + }) + } + } + } + sort.Slice(post.Tags, func(i, j int) bool { + return post.Tags[i].ID < post.Tags[j].ID + }) + + posts = append(posts, post) + } + + if err := rows.Err(); err != nil { + log.ErrorContext(ctx, "fail to scan rows", "error", err) + return nil, err + } + + log.InfoContext(ctx, "success search posts") + return posts, nil +} diff --git a/exercise7/blogging-platform/internal/db/post/update_post.go b/exercise7/blogging-platform/internal/db/post/update_post.go new file mode 100644 index 00000000..e20f395f --- /dev/null +++ b/exercise7/blogging-platform/internal/db/post/update_post.go @@ -0,0 +1,205 @@ +package post + +import ( + "context" + "database/sql" + "fmt" + + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/internal/db/blog" +) + +func (p *Post) UpdatePost(ctx context.Context, id_post int, req blog.PostRequest) (*blog.Post, error) { + log := p.logger.With("method", "UpdatePost") + + tx, err := p.db.BeginTx(ctx, nil) + if err != nil { + log.ErrorContext(ctx, "fail to begin transaction", "error", err) + return nil, err + } + defer tx.Rollback() + + id_category, err := p.GetCategoryIDByName(ctx, tx, req.Category) + if err != nil { + log.ErrorContext(ctx, "failed to get category by name", "error", err) + return nil, err + } + category := &blog.Category{ + ID: id_category, + Name: req.Category, + } + if id_category == 0 { + category, err = p.InsertCat(ctx, tx, *category) + if err != nil { + log.ErrorContext(ctx, "failed to create category", "error", err) + return nil, err + } + } + + id_category = category.ID + post, err := p.UpdateInfoPost(ctx, tx, id_post, id_category, req) + if err != nil { + log.ErrorContext(ctx, "failed to update post", "error", err) + return nil, err + } + post.Category = category + + tags, err := p.UpdateTags(ctx, tx, post.ID, req.Tags) + if err != nil { + log.ErrorContext(ctx, "failed to update tags", "error", err) + return nil, err + } + post.Tags = tags + + if err := tx.Commit(); err != nil { + log.ErrorContext(ctx, "fail to commit transaction", "error", err) + return nil, err + } + + log.InfoContext(ctx, "success update post") + + return post, nil +} + +func (p *Post) UpdateInfoPost(ctx context.Context, tx *sql.Tx, id_post int, id_category int, req blog.PostRequest) (*blog.Post, error) { + log := p.logger.With("method", "UpdatePost") + + var post blog.Post + query := `UPDATE post SET id_category = $1 , title = $2, content = $3, updated_at = NOW() WHERE id = $4 RETURNING id, title, content, created_at, updated_at` + err := tx.QueryRowContext(ctx, query, id_category, req.Title, req.Content, id_post).Scan(&post.ID, &post.Title, &post.Content, &post.CreatedAt, &post.UpdatedAt) + if err != nil { + if err == sql.ErrNoRows { + return nil, sql.ErrNoRows + } + log.ErrorContext(ctx, "failed to update post", "error", err) + return nil, err + } + + return &post, nil +} + +func (p *Post) UpdateTags(ctx context.Context, tx *sql.Tx, id_post int, tags []string) ([]*blog.Tag, error) { + currentTags, err := p.getCurrentTags(ctx, tx, id_post) + if err != nil { + return nil, err + } + + newTagIDs, err := p.processNewTags(ctx, tx, tags) + if err != nil { + return nil, err + } + err = p.deleteOldTags(ctx, tx, id_post, currentTags, newTagIDs) + if err != nil { + return nil, err + } + + err = p.addNewTags(ctx, tx, id_post, newTagIDs) + if err != nil { + return nil, err + } + + updatedTags, err := p.getCurrentTags(ctx, tx, id_post) + if err != nil { + return nil, err + } + + var tagsSlice []*blog.Tag + for _, tag := range updatedTags { + tagsSlice = append(tagsSlice, tag) + } + + return tagsSlice, nil +} + +func (p *Post) getCurrentTags(ctx context.Context, tx *sql.Tx, id_post int) (map[int]*blog.Tag, error) { + log := p.logger.With("method", "UpdatePost") + query := ` + SELECT t.id, t.name + FROM tag t + JOIN post_tags pt ON pt.id_tag = t.id + WHERE pt.id_post = $1 + ` + rows, err := tx.QueryContext(ctx, query, id_post) + if err != nil { + log.ErrorContext(ctx, "fail getting current tags for post", "error", err) + return nil, err + } + defer rows.Close() + + currentTags := make(map[int]*blog.Tag) + for rows.Next() { + var tag blog.Tag + if err := rows.Scan(&tag.ID, &tag.Name); err != nil { + log.ErrorContext(ctx, "fail to scan tag", "error", err) + return nil, err + } + currentTags[tag.ID] = &tag + } + + return currentTags, nil +} + +func (p *Post) processNewTags(ctx context.Context, tx *sql.Tx, tags []string) ([]int, error) { + log := p.logger.With("method", "UpdatePost") + var newTagIDs []int + for _, tagName := range tags { + var tag blog.Tag + err := tx.QueryRowContext(ctx, "SELECT id, name FROM tag WHERE name = $1", tagName).Scan(&tag.ID, &tag.Name) + if err != nil && err != sql.ErrNoRows { + log.ErrorContext(ctx, "fail to query tag", "error", err) + return nil, err + } + + if err == sql.ErrNoRows { + err := tx.QueryRowContext(ctx, "INSERT INTO tag(name) VALUES($1) RETURNING id", tagName).Scan(&tag.ID) + if err != nil { + p.logger.ErrorContext(ctx, "fail to insert new tag", "error", err) + return nil, err + } + } + newTagIDs = append(newTagIDs, tag.ID) + } + return newTagIDs, nil +} + +func (p *Post) deleteOldTags(ctx context.Context, tx *sql.Tx, id_post int, currentTags map[int]*blog.Tag, newTagIDs []int) error { + log := p.logger.With("method", "UpdatePost") + for tagID := range currentTags { + if !contains(newTagIDs, tagID) { + _, err := tx.ExecContext(ctx, "DELETE FROM post_tags WHERE id_post = $1 AND id_tag = $2", id_post, tagID) + if err != nil { + log.ErrorContext(ctx, fmt.Sprintf("fail to delete tag %d from post %d", tagID, id_post), "error", err) + return err + } + } + } + return nil +} + +func (p *Post) addNewTags(ctx context.Context, tx *sql.Tx, id_post int, newTagIDs []int) error { + log := p.logger.With("method", "UpdatePost") + for _, tagID := range newTagIDs { + var count int + err := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM post_tags WHERE id_post = $1 AND id_tag = $2", id_post, tagID).Scan(&count) + if err != nil { + log.ErrorContext(ctx, fmt.Sprintf("fail to check if post is already linked with tag: %d", tagID), "error", err) + return err + } + if count == 0 { + _, err := tx.ExecContext(ctx, "INSERT INTO post_tags(id_post, id_tag) VALUES($1, $2)", id_post, tagID) + if err != nil { + log.ErrorContext(ctx, fmt.Sprintf("fail to insert post_tag for post %d and tag %d", id_post, tagID), "error", err) + return err + } + } + } + return nil +} + +func contains(slice []int, val int) bool { + for _, v := range slice { + if v == val { + return true + } + } + return false +} diff --git a/exercise7/blogging-platform/internal/db/tag/all_posts_of_tag.go b/exercise7/blogging-platform/internal/db/tag/all_posts_of_tag.go new file mode 100644 index 00000000..d06be8a5 --- /dev/null +++ b/exercise7/blogging-platform/internal/db/tag/all_posts_of_tag.go @@ -0,0 +1,113 @@ +package tag + +import ( + "context" + "database/sql" + "sort" + "strconv" + "strings" + + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/internal/db/blog" +) + +func (t *Tag) GetAllPostsOfTag(ctx context.Context, id int) ([]blog.Post, error) { + log := t.logger.With("method", "GetAllPostsOfTag") + + query := ` + SELECT + p.id, + p.title, + p.content, + p.created_at, + p.updated_at, + c.id AS category_id, + c.name AS category_name, + STRING_AGG(t.id::TEXT, ',') AS tag_ids, + STRING_AGG(t.name, ',') AS tag_names + FROM + post p + JOIN category c ON p.id_category = c.id + LEFT JOIN post_tags pt ON p.id = pt.id_post + LEFT JOIN tag t ON pt.id_tag = t.id + WHERE + p.id IN ( + SELECT DISTINCT pt.id_post + FROM post_tags pt + WHERE pt.id_tag = $1 + ) + GROUP BY p.id, c.id + ORDER BY p.id; + ` + + rows, err := t.db.QueryContext(ctx, query, id) + if err != nil { + log.ErrorContext(ctx, "fail to query posts", "error", err) + return nil, err + } + defer rows.Close() + + var posts []blog.Post + + for rows.Next() { + var post blog.Post + var category blog.Category + var tagIDs, tagNames sql.NullString + + err := rows.Scan( + &post.ID, + &post.Title, + &post.Content, + &post.CreatedAt, + &post.UpdatedAt, + &category.ID, + &category.Name, + &tagIDs, + &tagNames, + ) + if err != nil { + log.ErrorContext(ctx, "fail to scan posts", "error", err) + return nil, err + } + + post.Category = &category + + if tagIDs.Valid && tagIDs.String != "" { + tagIDStrings := strings.Split(tagIDs.String, ",") + for _, idStr := range tagIDStrings { + tagID, err := strconv.Atoi(idStr) + if err == nil { + post.Tags = append(post.Tags, &blog.Tag{ + ID: tagID, + }) + } + } + } + + if tagNames.Valid && tagNames.String != "" { + tagNameStrings := strings.Split(tagNames.String, ",") + + for i, name := range tagNameStrings { + if i < len(post.Tags) { + post.Tags[i].Name = name + } else { + post.Tags = append(post.Tags, &blog.Tag{ + Name: name, + }) + } + } + } + sort.Slice(post.Tags, func(i, j int) bool { + return post.Tags[i].ID < post.Tags[j].ID + }) + + posts = append(posts, post) + } + + if err := rows.Err(); err != nil { + log.ErrorContext(ctx, "fail to scan rows", "error", err) + return nil, err + } + + log.InfoContext(ctx, "success get all posts of tag") + return posts, nil +} diff --git a/exercise7/blogging-platform/internal/db/tag/all_tags.go b/exercise7/blogging-platform/internal/db/tag/all_tags.go new file mode 100644 index 00000000..1bdc2175 --- /dev/null +++ b/exercise7/blogging-platform/internal/db/tag/all_tags.go @@ -0,0 +1,38 @@ +package tag + +import ( + "context" + + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/internal/db/blog" +) + +func (t *Tag) GetAllTags(ctx context.Context) ([]blog.Tag, error) { + log := t.logger.With("method", "GetAllTags") + + query := `SELECT id, name, created_at, updated_at FROM tag` + rows, err := t.db.QueryContext(ctx, query) + if err != nil { + log.ErrorContext(ctx, "fail to query tags", "error", err) + return nil, err + } + + defer rows.Close() + + var tags []blog.Tag + for rows.Next() { + var tag_one blog.Tag + if err := rows.Scan(&tag_one.ID, &tag_one.Name, &tag_one.CreatedAt, &tag_one.UpdatedAt); err != nil { + log.ErrorContext(ctx, "fail to scan tags", "error", err) + return nil, err + } + tags = append(tags, tag_one) + } + + if err := rows.Err(); err != nil { + log.ErrorContext(ctx, "fail to scan rows", "error", err) + return nil, err + } + + log.InfoContext(ctx, "success get all tags") + return tags, nil +} diff --git a/exercise7/blogging-platform/internal/db/tag/main.go b/exercise7/blogging-platform/internal/db/tag/main.go new file mode 100644 index 00000000..d72bc8bd --- /dev/null +++ b/exercise7/blogging-platform/internal/db/tag/main.go @@ -0,0 +1,18 @@ +package tag + +import ( + "database/sql" + "log/slog" +) + +type Tag struct { + logger *slog.Logger + db *sql.DB +} + +func New(db *sql.DB, logger *slog.Logger) *Tag { + return &Tag{ + logger: logger, + db: db, + } +} diff --git a/exercise7/blogging-platform/main.go b/exercise7/blogging-platform/main.go new file mode 100644 index 00000000..bfc01c1f --- /dev/null +++ b/exercise7/blogging-platform/main.go @@ -0,0 +1,65 @@ +package main + +import ( + "context" + "log/slog" + "os" + "os/signal" + + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/internal/api" + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/internal/db" + + "github.com/joho/godotenv" +) + +func main() { + ctx, cancel := context.WithCancel(context.Background()) + + _ = godotenv.Load() + + d, err := db.New(slog.With("service", "db")) + if err != nil { + slog.ErrorContext( + ctx, + "initialize service error", + "service", "db", + "error", err, + ) + panic(err) + } + + if err := d.Ping(ctx); err != nil { + panic(err) + } + + if err := d.Init(ctx); err != nil { + panic(err) + } + + a := api.New(slog.With("service", "api"), d) + if err := a.Start(ctx); err != nil { + slog.ErrorContext( + ctx, + "initialize service error", + "service", "api", + "error", err, + ) + panic(err) + } + + go func() { + 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) + + cancel() + }() + + if err := a.Stop(ctx); err != nil { + slog.ErrorContext(ctx, "service stop error", "error", err) + } + + slog.InfoContext(ctx, "server was successfully shutdown.") +} 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..62b9baf9 --- /dev/null +++ b/exercise7/blogging-platform/pkg/httputils/request/body.go @@ -0,0 +1,76 @@ +package request + +import ( + "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/pkg/httputils/statusError" + + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" +) + +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..d0c14e87 --- /dev/null +++ b/exercise7/blogging-platform/pkg/httputils/statusError/main.go @@ -0,0 +1,18 @@ +package statusError + +type StatusError struct { + num int + message string +} + +func New(status int, msg string) error { + return &StatusError{status, msg} +} + +func (st *StatusError) Error() string { + return st.message +} + +func (st *StatusError) Status() int { + return st.num +} diff --git a/exercise9/README.md b/exercise9/README.md new file mode 100644 index 00000000..ef1d468e --- /dev/null +++ b/exercise9/README.md @@ -0,0 +1,14 @@ +# Exercise 9 + +Project + +## Teams + +Team 1 + +1. Тұрарова Айзада (api) +2. Манкенов Арай (api) +3. Усербай Асылбек (controller) +4. Кемалатдин Ғалымжан (controller) +5. Имангали Аскар (db) +6. Кабдылкак Арнур (db) diff --git a/exercise9/go.mod b/exercise9/go.mod new file mode 100644 index 00000000..72f28b6f --- /dev/null +++ b/exercise9/go.mod @@ -0,0 +1,3 @@ +module github.com/talgat-ruby/exercises-go/exercise9 + +go 1.23.5 diff --git a/internal/cli/studentsBranch/constants.go b/internal/cli/studentsBranch/constants.go deleted file mode 100644 index 929f87a4..00000000 --- a/internal/cli/studentsBranch/constants.go +++ /dev/null @@ -1,4 +0,0 @@ -package main - -const cmdNameCreateBranches = "create-branches" -const cmdNameMergeMain = "merge-main" diff --git a/internal/cli/studentsBranch/main.go b/internal/cli/studentsBranch/main.go deleted file mode 100644 index b2b4d6cd..00000000 --- a/internal/cli/studentsBranch/main.go +++ /dev/null @@ -1,52 +0,0 @@ -package main - -import ( - _ "embed" - "flag" - "fmt" - "os" -) - -//go:embed students.txt -var studentsData string - -func main() { - cmdCreateBranches := newSubcmdCreateBranches() - cmdMergeMain := newSubcmdMergeMain() - - // Verify that a subcommand has been provided - // os.Arg[0] is the main command - // os.Arg[1] will be the subcommand - if len(os.Args) < 2 { - fmt.Printf( - "one of subcommands: %v is required\n", - []string{ - cmdCreateBranches.getCmdName(), - cmdMergeMain.getCmdName(), - }, - ) - os.Exit(1) - } - - // Switch on the subcommand - // Parse the flags for appropriate FlagSet - // FlagSet.Parse() requires a set of arguments to parse as input - // os.Args[2:] will be all arguments starting after the subcommand at os.Args[1] - switch os.Args[1] { - case cmdCreateBranches.getCmdName(): - if err := cmdCreateBranches.parse(os.Args[2:]); err != nil { - fmt.Printf("error in cmd %s: %+v", cmdCreateBranches.getCmdName(), err) - cmdCreateBranches.printDefaults() - os.Exit(1) - } - case cmdMergeMain.getCmdName(): - if err := cmdMergeMain.parse(os.Args[2:]); err != nil { - fmt.Printf("error in cmd %s: %+v", cmdMergeMain.getCmdName(), err) - cmdMergeMain.printDefaults() - os.Exit(1) - } - default: - flag.PrintDefaults() - os.Exit(1) - } -} diff --git a/internal/cli/studentsBranch/subcmd.go b/internal/cli/studentsBranch/subcmd.go deleted file mode 100644 index 031c26d3..00000000 --- a/internal/cli/studentsBranch/subcmd.go +++ /dev/null @@ -1,7 +0,0 @@ -package main - -type subcmd interface { - getCmdName() string - printDefaults() - parse([]string) error -} diff --git a/internal/cli/studentsBranch/subcmdCreateBranches.go b/internal/cli/studentsBranch/subcmdCreateBranches.go deleted file mode 100644 index 719da912..00000000 --- a/internal/cli/studentsBranch/subcmdCreateBranches.go +++ /dev/null @@ -1,62 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "sort" - "strings" -) - -type subcmdCreateBranches struct { - cmd *flag.FlagSet - students []string -} - -func newSubcmdCreateBranches() *subcmdCreateBranches { - cmd := flag.NewFlagSet(cmdNameCreateBranches, flag.ExitOnError) - - students := strings.Split(strings.TrimSpace(studentsData), "\n") - sort.Strings(students) - - return &subcmdCreateBranches{ - cmd: cmd, - students: students, - } -} - -func (s *subcmdCreateBranches) getCmdName() string { - return s.cmd.Name() -} - -func (s *subcmdCreateBranches) printDefaults() { - s.cmd.PrintDefaults() -} - -func (s *subcmdCreateBranches) parse(args []string) error { - if err := s.cmd.Parse(args); err != nil { - return err - } - - // Check which subcommand was Parsed using the FlagSet.Parsed() function. Handle each case accordingly. - // FlagSet.Parse() will evaluate to false if no flags were parsed (i.e. the user did not provide any flags) - if !s.cmd.Parsed() { - return fmt.Errorf("please provide correct arguments to %s command", s.cmd.Name()) - } - - if err := switchToBranch("main"); err != nil { - return fmt.Errorf("could not switch to main branch: %w", err) - } - - for _, student := range s.students { - if err := createRemoteBranch(student); err != nil { - return fmt.Errorf("could not create remote branch: %w", err) - } - fmt.Printf("created remote branch %s\n", student) - } - - if err := switchToBranch("main"); err != nil { - return fmt.Errorf("could not switch to main branch: %w", err) - } - - return nil -} diff --git a/internal/cli/studentsBranch/subcmdMergeMain.go b/internal/cli/studentsBranch/subcmdMergeMain.go deleted file mode 100644 index c2e7069c..00000000 --- a/internal/cli/studentsBranch/subcmdMergeMain.go +++ /dev/null @@ -1,68 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "sort" - "strings" -) - -type subcmdMergeMain struct { - cmd *flag.FlagSet - students []string -} - -func newSubcmdMergeMain() *subcmdMergeMain { - cmd := flag.NewFlagSet(cmdNameMergeMain, flag.ExitOnError) - - students := strings.Split(strings.TrimSpace(studentsData), "\n") - sort.Strings(students) - - return &subcmdMergeMain{ - cmd: cmd, - students: students, - } -} - -func (s *subcmdMergeMain) getCmdName() string { - return s.cmd.Name() -} - -func (s *subcmdMergeMain) printDefaults() { - s.cmd.PrintDefaults() -} - -func (s *subcmdMergeMain) parse(args []string) error { - if err := s.cmd.Parse(args); err != nil { - return err - } - - // Check which subcommand was Parsed using the FlagSet.Parsed() function. Handle each case accordingly. - // FlagSet.Parse() will evaluate to false if no flags were parsed (i.e. the user did not provide any flags) - if !s.cmd.Parsed() { - return fmt.Errorf("please provide correct arguments to %s command", s.cmd.Name()) - } - - if err := switchToBranch("main"); err != nil { - return fmt.Errorf("could not switch to main branch: %w", err) - } - - for _, student := range s.students { - if err := switchToBranch(student); err != nil { - return fmt.Errorf("could not switch to %s branch: %w", student, err) - } - if err := mergeFromLocal("main"); err != nil { - return fmt.Errorf("could not merge main to %s branch: %w", student, err) - } - if err := pushRemoteBranch(student); err != nil { - return fmt.Errorf("could not push to %s branch: %w", student, err) - } - fmt.Printf("merged main to %s\n", student) - } - - if err := switchToBranch("main"); err != nil { - return fmt.Errorf("could not switch to main branch: %w", err) - } - - return nil -} diff --git a/internal/cli/studentsBranch/utils.go b/internal/cli/studentsBranch/utils.go deleted file mode 100644 index 3c5904c7..00000000 --- a/internal/cli/studentsBranch/utils.go +++ /dev/null @@ -1,37 +0,0 @@ -package main - -import ( - "fmt" - "os/exec" -) - -func createRemoteBranch(branch string) error { - if err := switchToBranch(branch); err != nil { - return fmt.Errorf("could not switch to branch %s: %w", branch, err) - } - - if err := pushRemoteBranch(branch); err != nil { - return fmt.Errorf("could not push remote branch %s: %w", branch, err) - } - - if err := switchToBranch("main"); err != nil { - return fmt.Errorf("could not switch to main branch: %w", err) - } - - return nil -} - -func switchToBranch(branch string) error { - cmd := exec.Command("git", "switch", "-C", branch) - return cmd.Run() -} - -func mergeFromLocal(branch string) error { - cmd := exec.Command("git", "merge", branch) - return cmd.Run() -} - -func pushRemoteBranch(branch string) error { - cmd := exec.Command("git", "push", "--set-upstream", "origin", branch) - return cmd.Run() -}