diff --git a/exercise1/README.md b/exercise1/README.md index 888bfca8..24c5bb88 100644 --- a/exercise1/README.md +++ b/exercise1/README.md @@ -1,6 +1,6 @@ # Exercise 1 -Please provide solution for the following problems * are mandatory +Please provide solution for the following problems * is mandatory 1. [problem1](./problem1/README.md) * 2. [problem2](./problem2/README.md) * diff --git a/exercise1/problem1/main.go b/exercise1/problem1/main.go index dfca465c..5840b2b6 100644 --- a/exercise1/problem1/main.go +++ b/exercise1/problem1/main.go @@ -1,3 +1,9 @@ package main -func addUp() {} +func addUp(a int) int { + sum := 0 + for i := 0; i <= a; i++ { + sum += i + } + return sum +} diff --git a/exercise1/problem10/main.go b/exercise1/problem10/main.go index 04ec3430..f100b7b8 100644 --- a/exercise1/problem10/main.go +++ b/exercise1/problem10/main.go @@ -1,3 +1,20 @@ package main -func sum() {} +import ( + "fmt" + "strconv" +) + +func sum(a, b string) (string, error) { + numA, errA := strconv.Atoi(a) + if errA != nil { + return "", fmt.Errorf("string: %s cannot be converted", a) + } + numB, errB := strconv.Atoi(b) + if errB != nil { + return "", fmt.Errorf("string: %s cannot be converted", b) + } + sum := numA + numB + + return strconv.Itoa(sum), nil +} diff --git a/exercise1/problem2/main.go b/exercise1/problem2/main.go index 2ca540b8..a7d45c27 100644 --- a/exercise1/problem2/main.go +++ b/exercise1/problem2/main.go @@ -1,3 +1,20 @@ package main -func binary() {} +import "fmt" + +func binary(a int) string { + bi := "" + if a == 0 { + return "0" + } + for a > 0 { + bit := a % 2 + bi += fmt.Sprintf("%d", bit) + a /= 2 + } + runedbi := []rune(bi) + for i := 0; i < len(runedbi)/2; i++ { + runedbi[i], runedbi[len(runedbi)-1-i] = runedbi[len(runedbi)-1-i], runedbi[i] + } + return string(runedbi) +} diff --git a/exercise1/problem3/main.go b/exercise1/problem3/main.go index d346641a..7204877e 100644 --- a/exercise1/problem3/main.go +++ b/exercise1/problem3/main.go @@ -1,3 +1,9 @@ package main -func numberSquares() {} +func numberSquares(n int) int { + squares := 0 + for i := 1; i <= n; i++ { + squares += (n - i + 1) * (n - i + 1) + } + return squares +} diff --git a/exercise1/problem4/main.go b/exercise1/problem4/main.go index 74af9044..27b3a3a3 100644 --- a/exercise1/problem4/main.go +++ b/exercise1/problem4/main.go @@ -1,3 +1,13 @@ package main -func detectWord() {} +import "unicode" + +func detectWord(a string) string { + sum := "" + for _, char := range a { + if unicode.IsLower(char) { + sum += string(char) + } + } + return sum +} diff --git a/exercise1/problem5/main.go b/exercise1/problem5/main.go index c5a804c9..55c12381 100644 --- a/exercise1/problem5/main.go +++ b/exercise1/problem5/main.go @@ -1,3 +1,7 @@ package main -func potatoes() {} +import "strings" + +func potatoes(a string) int { + return strings.Count(a, "potato") +} diff --git a/exercise1/problem6/main.go b/exercise1/problem6/main.go index 06043890..097b62cf 100644 --- a/exercise1/problem6/main.go +++ b/exercise1/problem6/main.go @@ -1,3 +1,17 @@ package main -func emojify() {} +import "strings" + +func emojify(a string) string { + slovavsmile := map[string]string{ + "smile": "🙂", + "grin": "😀", + "sad": "😥", + "mad": "😠", + } + for i, slovo := range slovavsmile { + a = strings.ReplaceAll(a, i, slovo) + } + + return a +} diff --git a/exercise1/problem7/main.go b/exercise1/problem7/main.go index 57c99b5c..fdd79680 100644 --- a/exercise1/problem7/main.go +++ b/exercise1/problem7/main.go @@ -1,3 +1,14 @@ package main -func highestDigit() {} +func highestDigit(a int) int { + max := 0 + m := 0 + for a > 0 { + m = a % 10 + if m > max { + max = m + } + a /= 10 + } + return max +} diff --git a/exercise1/problem8/main.go b/exercise1/problem8/main.go index 97fa0dae..d31235bf 100644 --- a/exercise1/problem8/main.go +++ b/exercise1/problem8/main.go @@ -1,3 +1,14 @@ package main -func countVowels() {} +import "strings" + +func countVowels(a string) int { + vowels := "aeiouAEIOU" + sum := 0 + for _, ch := range a { + if strings.ContainsRune(vowels, ch) { + sum++ + } + } + return sum +} diff --git a/exercise1/problem9/main.go b/exercise1/problem9/main.go index e8c84a54..64f74a97 100644 --- a/exercise1/problem9/main.go +++ b/exercise1/problem9/main.go @@ -1,7 +1,13 @@ package main -func bitwiseAND() {} +func bitwiseAND(a, b int) int { + return a & b +} -func bitwiseOR() {} +func bitwiseOR(a, b int) int { + return a | b +} -func bitwiseXOR() {} +func bitwiseXOR(a, b int) int { + return a ^ b +} diff --git a/exercise2/problem1/problem1.go b/exercise2/problem1/problem1.go index 4763006c..de882b0f 100644 --- a/exercise2/problem1/problem1.go +++ b/exercise2/problem1/problem1.go @@ -1,4 +1,17 @@ package problem1 -func isChangeEnough() { +func isChangeEnough(change [4]int, amountDue float32) bool { + quartersValue := 25 + dimesValue := 10 + nickelsValue := 5 + pennieValue := 1 + + totalCents := (change[0] * quartersValue) + + (change[1] * dimesValue) + + (change[2] * nickelsValue) + + (change[3] * pennieValue) + + amountDueCents := int(amountDue * 100) + + return totalCents >= amountDueCents } diff --git a/exercise2/problem10/problem10.go b/exercise2/problem10/problem10.go index 7142a022..4d7857f7 100644 --- a/exercise2/problem10/problem10.go +++ b/exercise2/problem10/problem10.go @@ -1,3 +1,12 @@ package problem10 -func factory() {} +func factory() (map[string]int, func(string) func(int)) { + brands := make(map[string]int) + makeBrand := func(brand string) func(int) { + 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..a490e27b 100644 --- a/exercise2/problem11/problem11.go +++ b/exercise2/problem11/problem11.go @@ -1,3 +1,15 @@ package problem11 -func removeDups() {} +func removeDups[T comparable](items []T) []T { + seen := make(map[T]bool) + result := []T{} + + for _, item := range items { + if !seen[item] { + seen[item] = true + result = append(result, item) + } + } + + return result +} diff --git a/exercise2/problem12/problem12.go b/exercise2/problem12/problem12.go index 4c1ae327..b8f4a121 100644 --- a/exercise2/problem12/problem12.go +++ b/exercise2/problem12/problem12.go @@ -1,3 +1,24 @@ package problem11 -func keysAndValues() {} +import ( + "fmt" + "sort" +) + +func keysAndValues[K comparable, V any](m map[K]V) ([]K, []V) { + keys := make([]K, 0, len(m)) + values := make([]V, 0, len(m)) + for k, v := range m { + keys = append(keys, k) + values = append(values, v) + } + sort.Slice(keys, func(i, j int) bool { + return fmt.Sprintf("%v", keys[i]) < fmt.Sprintf("%v", keys[j]) + }) + sortedValues := make([]V, len(keys)) + for i, k := range keys { + sortedValues[i] = m[k] + } + + return keys, sortedValues +} diff --git a/exercise2/problem2/problem2.go b/exercise2/problem2/problem2.go index fdb199f0..7e2cb35d 100644 --- a/exercise2/problem2/problem2.go +++ b/exercise2/problem2/problem2.go @@ -1,4 +1,10 @@ package problem2 -func capitalize() { +import "strings" + +func capitalize(names []string) []string { + for i, name := range names { + names[i] = strings.Title(strings.ToLower(name)) + } + return names } diff --git a/exercise2/problem3/problem3.go b/exercise2/problem3/problem3.go index f183fafb..03a722e0 100644 --- a/exercise2/problem3/problem3.go +++ b/exercise2/problem3/problem3.go @@ -9,5 +9,38 @@ const ( lr dir = "lr" ) -func diagonalize() { +func diagonalize(n int, direction dir) [][]int { + matrix := make([][]int, n) + for i := range matrix { + matrix[i] = make([]int, n) + } + + switch direction { + case ul: + for i := 0; i < n; i++ { + for j := 0; j < n; j++ { + matrix[i][j] = i + j + } + } + case ur: + for i := 0; i < n; i++ { + for j := 0; j < n; j++ { + matrix[i][j] = (n - 1 - j) + i + } + } + case ll: + for i := 0; i < n; i++ { + for j := 0; j < n; j++ { + matrix[i][j] = (n - 1 - i) + j + } + } + case lr: + for i := 0; i < n; i++ { + for j := 0; j < n; j++ { + 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..b013d281 100644 --- a/exercise2/problem5/problem5.go +++ b/exercise2/problem5/problem5.go @@ -1,4 +1,23 @@ package problem5 -func products() { +import "sort" + +func products(prices map[string]int, minPrice int) []string { + + var result []string + + for product, price := range prices { + if price >= minPrice { + result = append(result, product) + } + } + + sort.Slice(result, func(i, j int) bool { + if prices[result[i]] == prices[result[j]] { + return result[i] < result[j] + } + return prices[result[i]] < prices[result[j]] + }) + + return result } diff --git a/exercise2/problem6/problem6.go b/exercise2/problem6/problem6.go index 89fc5bfe..1333e313 100644 --- a/exercise2/problem6/problem6.go +++ b/exercise2/problem6/problem6.go @@ -1,4 +1,15 @@ package problem6 -func sumOfTwo() { +func sumOfTwo(a []int, b []int, target int) bool { + complements := make(map[int]bool) + for _, num := range a { + complements[target-num] = true + } + for _, num := range b { + if complements[num] { + return true + } + } + + return false } diff --git a/exercise2/problem7/problem7.go b/exercise2/problem7/problem7.go index 32514209..6c9884d7 100644 --- a/exercise2/problem7/problem7.go +++ b/exercise2/problem7/problem7.go @@ -1,4 +1,7 @@ package problem7 -func swap() { +func swap(x *int, y *int) { + temp := *x + *x = *y + *y = temp } diff --git a/exercise2/problem8/problem8.go b/exercise2/problem8/problem8.go index 9389d3b0..3d2e169f 100644 --- a/exercise2/problem8/problem8.go +++ b/exercise2/problem8/problem8.go @@ -1,11 +1,8 @@ package problem8 func simplify(list []string) map[string]int { - var indMap map[string]int - - indMap = make(map[string]int) + indMap := make(map[string]int) load(&indMap, &list) - return indMap } diff --git a/exercise2/problem9/problem9.go b/exercise2/problem9/problem9.go index fc96d21a..9be1b79a 100644 --- a/exercise2/problem9/problem9.go +++ b/exercise2/problem9/problem9.go @@ -1,3 +1,11 @@ package problem9 -func factory() {} +func factory(factor int) func(...int) []int { + return func(nums ...int) []int { + results := make([]int, len(nums)) + for i, num := range nums { + results[i] = num * factor + } + return results + } +} diff --git a/exercise3/problem1/problem1.go b/exercise3/problem1/problem1.go index d45605c6..a0bc591d 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 { + items []any +} + +func (q *Queue) Enqueue(val any) { + q.items = append(q.items, val) +} + +func (q *Queue) Dequeue() (any, error) { + if len(q.items) == 0 { + return nil, errors.New("queue is empty") + } + element := q.items[0] + q.items = q.items[1:] + return element, nil +} + +func (q *Queue) Peek() (any, error) { + if len(q.items) == 0 { + return nil, errors.New("queue is empty") + } + return q.items[0], nil +} + +func (q *Queue) Size() int { + return len(q.items) +} + +func (q *Queue) IsEmpty() bool { + return len(q.items) == 0 +} diff --git a/exercise3/problem2/problem2.go b/exercise3/problem2/problem2.go index e9059889..f5aa7418 100644 --- a/exercise3/problem2/problem2.go +++ b/exercise3/problem2/problem2.go @@ -1,3 +1,37 @@ package problem2 -type Stack struct{} +import ( + "errors" +) + +type Stack struct { + items []any +} + +func (s *Stack) Push(val any) { + s.items = append(s.items, val) +} + +func (s *Stack) Pop() (any, error) { + if len(s.items) == 0 { + return nil, errors.New("stack is empty") + } + element := s.items[len(s.items)-1] + s.items = s.items[:len(s.items)-1] + return element, nil +} + +func (s *Stack) Peek() (any, error) { + if len(s.items) == 0 { + return nil, errors.New("stack is empty") + } + return s.items[len(s.items)-1], nil +} + +func (s *Stack) Size() int { + return len(s.items) +} + +func (s *Stack) IsEmpty() bool { + return len(s.items) == 0 +} diff --git a/exercise3/problem3/problem3.go b/exercise3/problem3/problem3.go index d8d79ac0..cf771df1 100644 --- a/exercise3/problem3/problem3.go +++ b/exercise3/problem3/problem3.go @@ -1,3 +1,91 @@ package problem3 -type Set struct{} +type Set struct { + items map[any]struct{} +} + +func NewSet() *Set { + return &Set{items: make(map[any]struct{})} +} + +func (s *Set) Add(val any) { + s.items[val] = struct{}{} +} + +func (s *Set) Remove(val any) { + delete(s.items, val) +} + +func (s *Set) IsEmpty() bool { + return len(s.items) == 0 +} + +func (s *Set) Size() int { + return len(s.items) +} + +func (s *Set) List() []any { + list := make([]any, 0, len(s.items)) + for k := range s.items { + list = append(list, k) + } + return list +} + +func (s *Set) Has(val any) bool { + _, exists := s.items[val] + return exists +} + +func (s *Set) Copy() *Set { + newSet := NewSet() + for k := range s.items { + newSet.Add(k) + } + return newSet +} + +func (s *Set) Difference(other *Set) *Set { + diff := NewSet() + for k := range s.items { + if !other.Has(k) { + diff.Add(k) + } + } + return diff +} + +func (s *Set) IsSubset(other *Set) bool { + for k := range s.items { + if !other.Has(k) { + return false + } + } + return true +} + +func Union(sets ...*Set) *Set { + unionSet := NewSet() + for _, set := range sets { + for k := range set.items { + unionSet.Add(k) + } + } + return unionSet +} + +func Intersect(sets ...*Set) *Set { + if len(sets) == 0 { + return NewSet() + } + + intersectSet := sets[0].Copy() + for _, set := range sets[1:] { + for k := range intersectSet.items { + if !set.Has(k) { + intersectSet.Remove(k) + } + } + } + return intersectSet +} diff --git a/exercise3/problem4/problem4.go b/exercise3/problem4/problem4.go index ebf78147..db1b1747 100644 --- a/exercise3/problem4/problem4.go +++ b/exercise3/problem4/problem4.go @@ -1,3 +1,101 @@ package problem4 -type LinkedList struct{} +import ( + "errors" +) + +type Element[T comparable] struct { + value T + next *Element[T] +} + +type LinkedList[T comparable] struct { + head *Element[T] + size int +} + +func (ll *LinkedList[T]) Add(el *Element[T]) { + if ll.head == nil { + ll.head = el + } else { + current := ll.head + for current.next != nil { + current = current.next + } + current.next = el + } + ll.size++ +} + +func (ll *LinkedList[T]) Insert(el *Element[T], pos int) error { + if pos < 0 || pos > ll.size { + return errors.New("index out of range") + } + if pos == 0 { + el.next = ll.head + ll.head = el + } else { + current := ll.head + for i := 0; i < pos-1; i++ { + current = current.next + } + el.next = current.next + current.next = el + } + ll.size++ + return nil +} + +func (ll *LinkedList[T]) Delete(el *Element[T]) error { + if ll.head == nil { + return errors.New("list is empty") + } + + if ll.head.value == el.value { + ll.head = ll.head.next + ll.size-- + return nil + } + + current := ll.head + for current.next != nil && current.next.value != el.value { + current = current.next + } + + if current.next == nil { + return errors.New("element not found") + } + + current.next = current.next.next + ll.size-- + return nil +} + +func (ll *LinkedList[T]) Find(value T) (*Element[T], error) { + current := ll.head + for current != nil { + if current.value == value { + return current, nil + } + current = current.next + } + return nil, errors.New("element not found") +} + +func (ll *LinkedList[T]) List() []T { + var result []T + current := ll.head + for current != nil { + result = append(result, current.value) + current = current.next + } + return result +} + +func (ll *LinkedList[T]) Size() int { + return ll.size +} + +func (ll *LinkedList[T]) IsEmpty() bool { + return ll.size == 0 +} diff --git a/exercise3/problem5/problem5.go b/exercise3/problem5/problem5.go index 4177599f..bd1777c2 100644 --- a/exercise3/problem5/problem5.go +++ b/exercise3/problem5/problem5.go @@ -1,3 +1,18 @@ package problem5 -type Person struct{} +import "fmt" + +type Person struct { + name string + age int +} + +func (p *Person) compareAge(other *Person) string { + if p.age < other.age { + return fmt.Sprintf("%s is older than me.", other.name) + } else if p.age > other.age { + return fmt.Sprintf("%s is younger than me.", other.name) + } else { + return fmt.Sprintf("%s is the same age as me.", other.name) + } +} diff --git a/exercise3/problem6/problem6.go b/exercise3/problem6/problem6.go index 4e8d1af8..aba44cb4 100644 --- a/exercise3/problem6/problem6.go +++ b/exercise3/problem6/problem6.go @@ -1,7 +1,31 @@ package problem6 -type Animal struct{} +type LegsProvider interface { + GetLegsNum() int +} -type Insect struct{} +type Animal struct { + name string + legsNum int +} -func sumOfAllLegsNum() {} +func (a *Animal) GetLegsNum() int { + return a.legsNum +} + +type Insect struct { + name string + legsNum int +} + +func (i *Insect) GetLegsNum() int { + return i.legsNum +} + +func sumOfAllLegsNum(entities ...LegsProvider) int { + totalLegs := 0 + for _, entity := range entities { + totalLegs += entity.GetLegsNum() + } + return totalLegs +} diff --git a/exercise3/problem7/problem7.go b/exercise3/problem7/problem7.go index 26887151..0b6b988e 100644 --- a/exercise3/problem7/problem7.go +++ b/exercise3/problem7/problem7.go @@ -1,10 +1,65 @@ package problem7 +import "fmt" + +type MoneyWithdrawable interface { + Withdraw(amount int) error +} + +type PackageSendable interface { + SendPackage(recipient string) +} + type BankAccount struct { + name string + balance int +} + +func (ba *BankAccount) Withdraw(amount int) error { + if ba.balance < amount { + return fmt.Errorf("insufficient funds") + } + ba.balance -= amount + return nil } type FedexAccount struct { + name string + packages []string +} + +func (fa *FedexAccount) SendPackage(recipient string) { + message := fmt.Sprintf("%s send package to %s", fa.name, recipient) + fa.packages = append(fa.packages, message) } type KazPostAccount struct { + name string + balance int + packages []string +} + +func (ka *KazPostAccount) Withdraw(amount int) error { + if ka.balance < amount { + return fmt.Errorf("insufficient funds") + } + ka.balance -= amount + return nil +} + +func (ka *KazPostAccount) SendPackage(recipient string) { + message := fmt.Sprintf("%s send package to %s", ka.name, recipient) + ka.packages = append(ka.packages, message) +} + +func withdrawMoney(amount int, accounts ...MoneyWithdrawable) { + for _, account := range accounts { + _ = account.Withdraw(amount) + } +} + +func sendPackagesTo(recipient string, accounts ...PackageSendable) { + for _, account := range accounts { + account.SendPackage(recipient) + } } diff --git a/exercise4/README.md b/exercise4/README.md new file mode 100644 index 00000000..20dc94b8 --- /dev/null +++ b/exercise4/README.md @@ -0,0 +1,41 @@ +# Tic tac to bot + +In this exercise create a bot to play with other bots game of tic-tac-toe. + +You have two projects `judge` and `bot`. + +* `judge` is the host app. On which you have to connect bot and play game. DON'T UPDATE IT. Try to understand it. You + can run it: + +```shell +$ PORT=4444 go run . +``` + +* `bot` is a boilerplate app for you bot. Don't delete anything, but rather add. + +## Requirements: + +1. You should have valid playing bot, any error from your side would be considered as a loose. +2. You don't need to understand everything. Just enough to make requests and accept requests. +3. You should be part of the team. Communication is a huge point. We will create separate channels so you can discuss + your works. + +## Basic Steps + +1. Join the game with a bot. Bot should respond to a `ping` request in order to be successfully added. +2. Game starts. Bot should respond on every request from `judge` + +## Technical part + +1. Make sure that your bot accepts env var `PORT`. (In video you saw, that my bot also accepts `NAME`, no need for + that). +2. For testing purpose you can start two bots with different `PORT`. + +## Presentation + +I made a [video](https://drive.proton.me/urls/R69RP8P504#UcriA7B8Ui8y) of entire process with valid bot. + +## End + +Please don't hesitate to ask any questions. Also, there are might be some bugs or some unforeseen steps. Notify me +please if you find anything unusual. diff --git a/exercise4/bot/go.mod b/exercise4/bot/go.mod new file mode 100644 index 00000000..e3a80fe1 --- /dev/null +++ b/exercise4/bot/go.mod @@ -0,0 +1,3 @@ +module github.com/talgat-ruby/exercises-go/exercise4/bot + +go 1.23.2 diff --git a/exercise4/bot/handler.go b/exercise4/bot/handler.go new file mode 100644 index 00000000..113ea244 --- /dev/null +++ b/exercise4/bot/handler.go @@ -0,0 +1,44 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" +) + +type MoveRequest struct { + GameID string `json:"game_id"` + Board []int `json:"board"` +} + +type MoveResponse struct { + Position int `json:"position"` +} + +func handlePing(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprintln(w, "pong") +} + +func handleMove(w http.ResponseWriter, r *http.Request) { + var req MoveRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + + move := calculateMove(req.Board) + + resp := MoveResponse{Position: move} + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +func calculateMove(board []int) int { + for i, val := range board { + if val == 0 { + return i + } + } + return -1 +} diff --git a/exercise4/bot/join.go b/exercise4/bot/join.go new file mode 100644 index 00000000..776750f8 --- /dev/null +++ b/exercise4/bot/join.go @@ -0,0 +1,44 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "log" + "net/http" + "os" +) + +type JoinRequest struct { + Name string `json:"name"` + URL string `json:"url"` +} + +func joinGame() error { + joinURL := "http://localhost:4444/join" + botName := "MyTicTacToeBot" + botURL := "http://localhost:" + os.Getenv("PORT") + + reqBody := JoinRequest{ + Name: botName, + URL: botURL, + } + + jsonBody, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("failed to marshal join request: %w", err) + } + + resp, err := http.Post(joinURL, "application/json", bytes.NewBuffer(jsonBody)) + if err != nil { + return fmt.Errorf("failed to send join request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent { + return fmt.Errorf("failed to join the game, status: %d", resp.StatusCode) + } + + log.Println("Bot successfully joined the game!") + return nil +} diff --git a/exercise4/bot/main.go b/exercise4/bot/main.go new file mode 100644 index 00000000..067d7232 --- /dev/null +++ b/exercise4/bot/main.go @@ -0,0 +1,24 @@ +package main + +import ( + "log" + "os" + "os/signal" + "syscall" +) + +func main() { + + ready := startServer() + <-ready + + // Присоединяемся к игре + err := joinGame() + if err != nil { + log.Fatalf("Failed to join the game: %v", err) + } + + stop := make(chan os.Signal, 1) + signal.Notify(stop, os.Interrupt, syscall.SIGTERM) + <-stop // Ожидание завершения по SIGINT или SIGTERM +} diff --git a/exercise4/bot/server.go b/exercise4/bot/server.go new file mode 100644 index 00000000..6b29fa0b --- /dev/null +++ b/exercise4/bot/server.go @@ -0,0 +1,48 @@ +package main + +import ( + "errors" + "fmt" + "net" + "net/http" + "os" + "sync" + "time" +) + +type readyListener struct { + net.Listener + ready chan struct{} + once sync.Once +} + +func (l *readyListener) Accept() (net.Conn, error) { + l.once.Do(func() { close(l.ready) }) + return l.Listener.Accept() +} + +func startServer() <-chan struct{} { + ready := make(chan struct{}) + + listener, err := net.Listen("tcp", fmt.Sprintf(":%s", os.Getenv("PORT"))) + if err != nil { + panic(err) + } + + list := &readyListener{Listener: listener, ready: ready} + srv := &http.Server{ + IdleTimeout: 2 * time.Minute, + } + + http.HandleFunc("/ping", handlePing) // Пинг + http.HandleFunc("/move", handleMove) // Ходы + + go func() { + err := srv.Serve(list) + if !errors.Is(err, http.ErrServerClosed) { + panic(err) + } + }() + + return ready +} diff --git a/exercise4/judge/go.mod b/exercise4/judge/go.mod new file mode 100644 index 00000000..47e8bb6e --- /dev/null +++ b/exercise4/judge/go.mod @@ -0,0 +1,3 @@ +module github.com/talgat-ruby/exercises-go/exercise4/judge + +go 1.23.2 diff --git a/exercise4/judge/internal/api/handler/join.go b/exercise4/judge/internal/api/handler/join.go new file mode 100644 index 00000000..f22bec8d --- /dev/null +++ b/exercise4/judge/internal/api/handler/join.go @@ -0,0 +1,79 @@ +package handler + +import ( + "fmt" + "log/slog" + "net/http" + + "github.com/talgat-ruby/exercises-go/exercise4/judge/internal/ticTacToe/player" + "github.com/talgat-ruby/exercises-go/exercise4/judge/pkg/httputils/request" +) + +type RequestJoin struct { + Name string `json:"name"` + URL string `json:"url"` +} + +type ResponseJoin struct { + Message string `json:"name"` +} + +func (h *Handler) Join(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := slog.With( + "handler", "Join", + "path", r.URL.Path, + ) + + var reqBody RequestJoin + if err := request.JSON(w, r, &reqBody); err != nil { + log.ErrorContext( + ctx, + "failed to parse json body", + "error", err, + ) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // create a player + p := player.New(reqBody.Name, reqBody.URL) + + // check if a player responses to a ping + if err := p.Ping(ctx); err != nil { + log.ErrorContext( + ctx, + "failed to ping player", + "player.name", p.Name, + "player.remote", p.URL, + "error", err, + ) + http.Error( + w, + fmt.Errorf("ping to url failed %w, check if player is running", err).Error(), + http.StatusBadRequest, + ) + return + } + + // add a player to the game + if err := h.game.AddPlayer(ctx, p); err != nil { + log.ErrorContext( + ctx, + "player was not added", + "player.name", p.Name, + "player.url", p.URL, + "error", err, + ) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + log.InfoContext( + ctx, + "new player joined the game", + "player.name", p.Name, + "player.url", p.URL, + ) + w.WriteHeader(http.StatusNoContent) +} diff --git a/exercise4/judge/internal/api/handler/main.go b/exercise4/judge/internal/api/handler/main.go new file mode 100644 index 00000000..98948542 --- /dev/null +++ b/exercise4/judge/internal/api/handler/main.go @@ -0,0 +1,15 @@ +package handler + +import ( + "github.com/talgat-ruby/exercises-go/exercise4/judge/internal/ticTacToe/game" +) + +type Handler struct { + game *game.Game +} + +func New() *Handler { + return &Handler{ + game: game.New(), + } +} diff --git a/exercise4/judge/internal/api/handler/start.go b/exercise4/judge/internal/api/handler/start.go new file mode 100644 index 00000000..b708ef5d --- /dev/null +++ b/exercise4/judge/internal/api/handler/start.go @@ -0,0 +1,31 @@ +package handler + +import ( + "log/slog" + "net/http" +) + +func (h *Handler) Start(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := slog.With( + "handler", "Start", + "path", r.URL.Path, + ) + + // add a player to the game + if err := h.game.Start(ctx); err != nil { + log.ErrorContext( + ctx, + "game was not started", + "error", err, + ) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + log.InfoContext( + ctx, + "game started", + ) + w.WriteHeader(http.StatusNoContent) +} diff --git a/exercise4/judge/internal/api/handler/status.go b/exercise4/judge/internal/api/handler/status.go new file mode 100644 index 00000000..8afa73ef --- /dev/null +++ b/exercise4/judge/internal/api/handler/status.go @@ -0,0 +1,38 @@ +package handler + +import ( + "log/slog" + "net/http" + + "github.com/talgat-ruby/exercises-go/exercise4/judge/internal/ticTacToe/game" + "github.com/talgat-ruby/exercises-go/exercise4/judge/pkg/httputils/response" +) + +type ResponseStatus struct { + Data *game.Game `json:"data"` +} + +func (h *Handler) Status(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := slog.With( + "handler", "Status", + "path", r.URL.Path, + ) + + h.game.Status() + + if err := response.JSON( + w, + http.StatusOK, + ResponseStatus{ + Data: h.game, + }, + ); err != nil { + log.ErrorContext( + ctx, + "fail json", + "error", err, + ) + return + } +} diff --git a/exercise4/judge/internal/api/main.go b/exercise4/judge/internal/api/main.go new file mode 100644 index 00000000..7dbb3688 --- /dev/null +++ b/exercise4/judge/internal/api/main.go @@ -0,0 +1,55 @@ +package api + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + + "github.com/talgat-ruby/exercises-go/exercise4/judge/internal/api/router" +) + +type Api struct { + srv *http.Server +} + +func New() *Api { + return &Api{} +} + +func (api *Api) Start(ctx context.Context, port string) error { + r := router.New() + + // start up HTTP server + api.srv = &http.Server{ + Addr: fmt.Sprintf(":%s", port), + Handler: r, + BaseContext: func(_ net.Listener) context.Context { + return ctx + }, + } + + slog.InfoContext( + ctx, + "starting service", + "port", port, + ) + + if err := api.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.srv.Shutdown(ctx); err != nil { + slog.ErrorContext(ctx, "server shutdown error", "error", err) + return err + } + + return nil +} diff --git a/exercise4/judge/internal/api/router/main.go b/exercise4/judge/internal/api/router/main.go new file mode 100644 index 00000000..5b7c2a8b --- /dev/null +++ b/exercise4/judge/internal/api/router/main.go @@ -0,0 +1,20 @@ +package router + +import ( + "net/http" + + "github.com/talgat-ruby/exercises-go/exercise4/judge/internal/api/handler" +) + +func New() *http.ServeMux { + han := handler.New() + mux := http.NewServeMux() + + mux.Handle("GET /status", http.HandlerFunc(han.Status)) + mux.Handle("POST /join", http.HandlerFunc(han.Join)) + + // Only admin can start game, start it manually + mux.Handle("POST /start", http.HandlerFunc(han.Start)) + + return mux +} diff --git a/exercise4/judge/internal/constant.go b/exercise4/judge/internal/constant.go new file mode 100644 index 00000000..a18536aa --- /dev/null +++ b/exercise4/judge/internal/constant.go @@ -0,0 +1,9 @@ +package internal + +type Token string + +const ( + TokenEmpty Token = " " + TokenX Token = "x" + TokenO Token = "o" +) diff --git a/exercise4/judge/internal/ticTacToe/board/copy.go b/exercise4/judge/internal/ticTacToe/board/copy.go new file mode 100644 index 00000000..57dd92dd --- /dev/null +++ b/exercise4/judge/internal/ticTacToe/board/copy.go @@ -0,0 +1,7 @@ +package board + +func (b *Board) Copy() *Board { + var cpy Board + cpy = *b + return &cpy +} diff --git a/exercise4/judge/internal/ticTacToe/board/evaluate.go b/exercise4/judge/internal/ticTacToe/board/evaluate.go new file mode 100644 index 00000000..8ca2676c --- /dev/null +++ b/exercise4/judge/internal/ticTacToe/board/evaluate.go @@ -0,0 +1,56 @@ +package board + +import ( + "github.com/talgat-ruby/exercises-go/exercise4/judge/internal" +) + +// Evaluate returns winner token, if draw empty token, nill if game still continues +func (b *Board) Evaluate(currentInd int, currentT internal.Token) *internal.Token { + isWin := false + firstInRowInd := (currentInd / Cols) * Cols + firstInColInd := currentInd % Cols + + isWin = + //row + b.assess(currentT, firstInRowInd, Cols, 1) || + // col + b.assess(currentT, firstInColInd, Rows, Cols) || + // diagonal 1 + b.assess(currentT, 0, Cols, Rows+1) || + // diagonal 2 + b.assess(currentT, Rows-1, Cols, Rows-1) + + if isWin { + return ¤tT + } + + if b.hasNoEmpty() { + draw := internal.TokenEmpty + return &draw + } + + return nil +} + +func (b *Board) assess(t internal.Token, firstInd, total, step int) bool { + i := firstInd + + for range total { + if b[i] != t { + return false + } + i += step + } + + return true +} + +func (b *Board) hasNoEmpty() bool { + for _, t := range b { + if t == internal.TokenEmpty { + return false + } + } + + return true +} diff --git a/exercise4/judge/internal/ticTacToe/board/main.go b/exercise4/judge/internal/ticTacToe/board/main.go new file mode 100644 index 00000000..c12ba9ad --- /dev/null +++ b/exercise4/judge/internal/ticTacToe/board/main.go @@ -0,0 +1,21 @@ +package board + +import ( + "slices" + + "github.com/talgat-ruby/exercises-go/exercise4/judge/internal" +) + +const ( + Cols = 3 + Rows = 3 +) + +type Board [Cols * Rows]internal.Token + +func New() *Board { + board := &Board{} + slc := slices.Repeat([]internal.Token{internal.TokenEmpty}, Cols*Rows) + copy(board[:], slc) + return board +} diff --git a/exercise4/judge/internal/ticTacToe/board/set.go b/exercise4/judge/internal/ticTacToe/board/set.go new file mode 100644 index 00000000..95800dce --- /dev/null +++ b/exercise4/judge/internal/ticTacToe/board/set.go @@ -0,0 +1,16 @@ +package board + +import ( + "fmt" + + "github.com/talgat-ruby/exercises-go/exercise4/judge/internal" +) + +func (b *Board) Set(i int, t internal.Token) error { + if b[i] != internal.TokenEmpty { + return fmt.Errorf("selected cell(#%d) is already set to %v ", i, b[i]) + } + + b[i] = t + return nil +} diff --git a/exercise4/judge/internal/ticTacToe/board/string.go b/exercise4/judge/internal/ticTacToe/board/string.go new file mode 100644 index 00000000..5730e52b --- /dev/null +++ b/exercise4/judge/internal/ticTacToe/board/string.go @@ -0,0 +1,28 @@ +package board + +import ( + "fmt" +) + +func (b *Board) String() string { + str := fmt.Sprintf( + ` + %s|%s|%s + ----- + %s|%s|%s + ----- + %s|%s|%s +`, + b[0], + b[1], + b[2], + b[3], + b[4], + b[5], + b[6], + b[7], + b[8], + ) + + return str +} diff --git a/exercise4/judge/internal/ticTacToe/game/addPlayer.go b/exercise4/judge/internal/ticTacToe/game/addPlayer.go new file mode 100644 index 00000000..861fd872 --- /dev/null +++ b/exercise4/judge/internal/ticTacToe/game/addPlayer.go @@ -0,0 +1,31 @@ +package game + +import ( + "context" + "fmt" + + "github.com/talgat-ruby/exercises-go/exercise4/judge/internal/ticTacToe/player" +) + +func (g *Game) AddPlayer(ctx context.Context, newPlayer *player.Player) error { + if g.State != StatePending { + return fmt.Errorf("game has already started") + } + + if g.isPlayerUrlExists(ctx, newPlayer) { + return fmt.Errorf("player with url already exists") + } + + g.Players = append(g.Players, newPlayer) + return nil +} + +func (g *Game) isPlayerUrlExists(_ context.Context, player *player.Player) bool { + for _, p := range g.Players { + if p.URL == player.URL { + return true + } + } + + return false +} diff --git a/exercise4/judge/internal/ticTacToe/game/leaderboard.go b/exercise4/judge/internal/ticTacToe/game/leaderboard.go new file mode 100644 index 00000000..21f4b23d --- /dev/null +++ b/exercise4/judge/internal/ticTacToe/game/leaderboard.go @@ -0,0 +1,72 @@ +package game + +import ( + "fmt" + "sort" + "strings" + + "github.com/talgat-ruby/exercises-go/exercise4/judge/internal/ticTacToe/player" +) + +const ( + PointsWin = 2 + PointsDraw = 1 + PointsLose = 0 +) + +type PlayerWithPoints struct { + *player.Player + points int +} + +func (g *Game) leaderboard() string { + pls := g.getAllPlayersWithPoints() + + strs := make([]string, 0, len(pls)) + for i, p := range pls { + strs = append(strs, fmt.Sprintf("%d. %s %s %d", i+1, p.Name, p.URL, p.points)) + } + + return strings.Join(strs, "\n") +} + +func (g *Game) getAllPlayersWithPoints() []*PlayerWithPoints { + plsM := make(map[string]*PlayerWithPoints, len(g.Players)) + + for _, p := range g.Players { + pl := &PlayerWithPoints{p, 0} + plsM[pl.URL] = pl + } + + for _, m := range g.Matches { + for _, r := range m.Rounds { + if r.Winner == nil { + plsM[r.Players[0].URL].points += PointsDraw + plsM[r.Players[1].URL].points += PointsDraw + break + } + + if r.Winner.URL == r.Players[0].URL { + plsM[r.Players[0].URL].points += PointsWin + plsM[r.Players[1].URL].points += PointsLose + break + } + + plsM[r.Players[0].URL].points += PointsLose + plsM[r.Players[1].URL].points += PointsWin + } + } + + pls := make([]*PlayerWithPoints, 0, len(plsM)) + for _, p := range plsM { + pls = append(pls, p) + } + + sort.Slice( + pls, func(i, j int) bool { + return pls[i].points > pls[j].points + }, + ) + + return pls +} diff --git a/exercise4/judge/internal/ticTacToe/game/main.go b/exercise4/judge/internal/ticTacToe/game/main.go new file mode 100644 index 00000000..6dec06c4 --- /dev/null +++ b/exercise4/judge/internal/ticTacToe/game/main.go @@ -0,0 +1,28 @@ +package game + +import ( + "github.com/talgat-ruby/exercises-go/exercise4/judge/internal/ticTacToe/match" + "github.com/talgat-ruby/exercises-go/exercise4/judge/internal/ticTacToe/player" +) + +type State string + +const ( + StatePending State = "PENDING" + StateRunning State = "RUNNING" + StateFinished State = "FINISHED" +) + +type Game struct { + State State `json:"state"` + Players []*player.Player `json:"players"` + Matches []*match.Match `json:"matches"` +} + +func New() *Game { + return &Game{ + State: StatePending, + Players: []*player.Player{}, + Matches: []*match.Match{}, + } +} diff --git a/exercise4/judge/internal/ticTacToe/game/start.go b/exercise4/judge/internal/ticTacToe/game/start.go new file mode 100644 index 00000000..fa388f45 --- /dev/null +++ b/exercise4/judge/internal/ticTacToe/game/start.go @@ -0,0 +1,64 @@ +package game + +import ( + "context" + "fmt" + "time" + + "github.com/talgat-ruby/exercises-go/exercise4/judge/internal/ticTacToe/match" + "github.com/talgat-ruby/exercises-go/exercise4/judge/internal/ticTacToe/player" +) + +func (g *Game) Start(ctx context.Context) error { + if g.State != StatePending { + return fmt.Errorf("game state is %s", g.State) + } + + if len(g.Players) < 2 { + return fmt.Errorf("game has no enough players, %d player(s)", len(g.Players)) + } + + g.startPrint() + + g.State = StateRunning + + go func(ctx context.Context, players []*player.Player) { + for i := 0; i < len(players); i++ { + for j := i + 1; j < len(players); j++ { + pls := [2]*player.Player{players[i], players[j]} + newMatch := match.New(pls) + g.Matches = append(g.Matches, newMatch) + newMatch.Start(ctx, len(g.Matches)) + } + } + g.endPrint() + }(ctx, g.Players) + + return nil +} + +func (g *Game) startPrint() { + fmt.Printf( + ` +Starting Game! + +Our players are: +%s +`, + g.playersList(), + ) + <-time.After(time.Second) +} + +func (g *Game) endPrint() { + fmt.Printf( + ` +Game Completed! + +Leaderboard: +%s +`, + g.leaderboard(), + ) + <-time.After(time.Second) +} diff --git a/exercise4/judge/internal/ticTacToe/game/status.go b/exercise4/judge/internal/ticTacToe/game/status.go new file mode 100644 index 00000000..4bdb6f94 --- /dev/null +++ b/exercise4/judge/internal/ticTacToe/game/status.go @@ -0,0 +1,31 @@ +package game + +import ( + "fmt" + "strings" +) + +func (g *Game) Status() { + fmt.Printf( + ` +Game status: %s + +Players: +%s + +`, + g.State, + g.playersList(), + ) +} + +func (g *Game) playersList() string { + strs := make([]string, 0, len(g.Players)) + + for i, player := range g.Players { + str := fmt.Sprintf("%d %s %s", i+1, player.Name, player.URL) + strs = append(strs, str) + } + + return strings.Join(strs, "\n") +} diff --git a/exercise4/judge/internal/ticTacToe/match/main.go b/exercise4/judge/internal/ticTacToe/match/main.go new file mode 100644 index 00000000..1bf4e698 --- /dev/null +++ b/exercise4/judge/internal/ticTacToe/match/main.go @@ -0,0 +1,18 @@ +package match + +import ( + "github.com/talgat-ruby/exercises-go/exercise4/judge/internal/ticTacToe/player" + "github.com/talgat-ruby/exercises-go/exercise4/judge/internal/ticTacToe/round" +) + +type Match struct { + Players [2]*player.Player `json:"players"` + Rounds []*round.Round `json:"rounds"` +} + +func New(players [2]*player.Player) *Match { + return &Match{ + Players: players, + Rounds: []*round.Round{}, + } +} diff --git a/exercise4/judge/internal/ticTacToe/match/start.go b/exercise4/judge/internal/ticTacToe/match/start.go new file mode 100644 index 00000000..aca76320 --- /dev/null +++ b/exercise4/judge/internal/ticTacToe/match/start.go @@ -0,0 +1,73 @@ +package match + +import ( + "context" + "fmt" + "time" + + "github.com/talgat-ruby/exercises-go/exercise4/judge/internal" + "github.com/talgat-ruby/exercises-go/exercise4/judge/internal/ticTacToe/player" + "github.com/talgat-ruby/exercises-go/exercise4/judge/internal/ticTacToe/round" +) + +var tokens = [2]internal.Token{internal.TokenX, internal.TokenO} + +func (m *Match) Start(ctx context.Context, num int) { + m.startPrint(num) + + for i := range m.Players { + for j := range tokens { + pl1 := m.Players[i%len(m.Players)].SetToken(tokens[j%len(tokens)]) + pl2 := m.Players[(i+1)%len(m.Players)].SetToken(tokens[(j+1)%len(tokens)]) + pls := [2]*player.Player{pl1, pl2} + newRound := round.New(pls) + m.Rounds = append(m.Rounds, newRound) + newRound.Start(ctx, len(m.Rounds)) + } + } + + m.endPrint(num) +} + +func (m *Match) startPrint(num int) { + fmt.Printf( + ` +Match #%d: %s %s vs %s %s +`, + num, + m.Players[0].Name, + m.Players[0].URL, + m.Players[1].Name, + m.Players[1].URL, + ) + <-time.After(time.Second) +} + +func (m *Match) endPrint(num int) { + fmt.Printf( + ` +End match #%d: %s %s %d:%d %s %s +`, + num, + m.Players[0].Name, + m.Players[0].URL, + m.totalRoundsWonBy(m.Players[0]), + m.totalRoundsWonBy(m.Players[1]), + m.Players[1].Name, + m.Players[1].URL, + ) + <-time.After(time.Second) +} + +func (m *Match) totalRoundsWonBy(p *player.Player) int { + total := 0 + + for _, r := range m.Rounds { + if r.Winner != nil && r.Winner.URL == p.URL { + total += 1 + break + } + } + + return total +} diff --git a/exercise4/judge/internal/ticTacToe/move/main.go b/exercise4/judge/internal/ticTacToe/move/main.go new file mode 100644 index 00000000..a97fd599 --- /dev/null +++ b/exercise4/judge/internal/ticTacToe/move/main.go @@ -0,0 +1,18 @@ +package move + +import ( + "github.com/talgat-ruby/exercises-go/exercise4/judge/internal/ticTacToe/board" + "github.com/talgat-ruby/exercises-go/exercise4/judge/internal/ticTacToe/player" +) + +type Move struct { + Player *player.Player `json:"players"` + Board *board.Board `json:"board"` +} + +func New(p *player.Player, b *board.Board) *Move { + return &Move{ + Player: p, + Board: b, + } +} diff --git a/exercise4/judge/internal/ticTacToe/move/start.go b/exercise4/judge/internal/ticTacToe/move/start.go new file mode 100644 index 00000000..f8b03843 --- /dev/null +++ b/exercise4/judge/internal/ticTacToe/move/start.go @@ -0,0 +1,45 @@ +package move + +import ( + "context" + "fmt" + "time" +) + +func (m *Move) Start(ctx context.Context) (int, error) { + ind, err := m.apply(ctx) + m.Board = m.Board.Copy() + if err != nil { + return 0, err + } + + m.print() + + return ind, nil +} + +func (m *Move) apply(ctx context.Context) (int, error) { + ind, err := m.Player.Move(ctx, m.Board) + if err != nil { + return 0, err + } + + if err := m.Board.Set(ind, *m.Player.Token); err != nil { + return 0, err + } + + return ind, nil +} + +func (m *Move) print() { + fmt.Printf( + ` +Move by %s(%s) +%s +`, + m.Player.Name, + *m.Player.Token, + m.Board.String(), + ) + <-time.After(time.Second) +} diff --git a/exercise4/judge/internal/ticTacToe/player/main.go b/exercise4/judge/internal/ticTacToe/player/main.go new file mode 100644 index 00000000..dc937b1b --- /dev/null +++ b/exercise4/judge/internal/ticTacToe/player/main.go @@ -0,0 +1,19 @@ +package player + +import ( + "github.com/talgat-ruby/exercises-go/exercise4/judge/internal" +) + +type Player struct { + Name string `json:"name"` + URL string `json:"url"` + Token *internal.Token `json:"token,omitempty"` +} + +func New(name string, url string) *Player { + return &Player{ + Name: name, + URL: url, + Token: nil, + } +} diff --git a/exercise4/judge/internal/ticTacToe/player/move.go b/exercise4/judge/internal/ticTacToe/player/move.go new file mode 100644 index 00000000..a740202d --- /dev/null +++ b/exercise4/judge/internal/ticTacToe/player/move.go @@ -0,0 +1,73 @@ +package player + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/talgat-ruby/exercises-go/exercise4/judge/internal" + "github.com/talgat-ruby/exercises-go/exercise4/judge/internal/ticTacToe/board" +) + +type RequestMove struct { + Board *board.Board `json:"board"` + Token internal.Token `json:"token"` +} + +type ResponseMove struct { + Index int `json:"index"` +} + +func (p *Player) Move(ctx context.Context, b *board.Board) (int, error) { + // timeout after 5 sec + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + move := RequestMove{ + Board: b, + Token: *p.Token, + } + jsonData, err := json.Marshal(move) + if err != nil { + return 0, fmt.Errorf("failed to marshal move request: %w", err) + } + + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + fmt.Sprintf("%s/move", p.URL), + bytes.NewBuffer(jsonData), + ) + if err != nil { + return 0, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return 0, fmt.Errorf("failed to make request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return 0, fmt.Errorf("failed to read response body: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return 0, fmt.Errorf("request failed with status code: %d, body: %s", resp.StatusCode, string(body)) + } + + var respBody ResponseMove + err = json.Unmarshal(body, &respBody) + if err != nil { + return 0, fmt.Errorf("failed to unmarshal response body: %w", err) + } + + return respBody.Index, nil +} diff --git a/exercise4/judge/internal/ticTacToe/player/ping.go b/exercise4/judge/internal/ticTacToe/player/ping.go new file mode 100644 index 00000000..df50bb90 --- /dev/null +++ b/exercise4/judge/internal/ticTacToe/player/ping.go @@ -0,0 +1,35 @@ +package player + +import ( + "context" + "fmt" + "net/http" + "time" +) + +func (p *Player) Ping(ctx context.Context) error { + // timeout after 5 sec + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + fmt.Sprintf("%s/ping", p.URL), + nil, + ) + if err != nil { + return fmt.Errorf("error creating request %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("error making ping request %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("failed making ping request %d - %s", resp.StatusCode, http.StatusText(resp.StatusCode)) + } + + return nil +} diff --git a/exercise4/judge/internal/ticTacToe/player/token.go b/exercise4/judge/internal/ticTacToe/player/token.go new file mode 100644 index 00000000..cf4662bd --- /dev/null +++ b/exercise4/judge/internal/ticTacToe/player/token.go @@ -0,0 +1,11 @@ +package player + +import ( + "github.com/talgat-ruby/exercises-go/exercise4/judge/internal" +) + +func (p *Player) SetToken(token internal.Token) *Player { + cp := *p + cp.Token = &token + return &cp +} diff --git a/exercise4/judge/internal/ticTacToe/round/main.go b/exercise4/judge/internal/ticTacToe/round/main.go new file mode 100644 index 00000000..e8dc2239 --- /dev/null +++ b/exercise4/judge/internal/ticTacToe/round/main.go @@ -0,0 +1,23 @@ +package round + +import ( + "github.com/talgat-ruby/exercises-go/exercise4/judge/internal/ticTacToe/board" + "github.com/talgat-ruby/exercises-go/exercise4/judge/internal/ticTacToe/move" + "github.com/talgat-ruby/exercises-go/exercise4/judge/internal/ticTacToe/player" +) + +type Round struct { + Players [2]*player.Player `json:"players"` + Board *board.Board `json:"board"` + Moves []*move.Move `json:"moves"` + Winner *player.Player `json:"winner"` +} + +func New(players [2]*player.Player) *Round { + return &Round{ + Players: players, + Board: board.New(), + Moves: []*move.Move{}, + Winner: nil, + } +} diff --git a/exercise4/judge/internal/ticTacToe/round/start.go b/exercise4/judge/internal/ticTacToe/round/start.go new file mode 100644 index 00000000..d72669cd --- /dev/null +++ b/exercise4/judge/internal/ticTacToe/round/start.go @@ -0,0 +1,82 @@ +package round + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/talgat-ruby/exercises-go/exercise4/judge/internal" + "github.com/talgat-ruby/exercises-go/exercise4/judge/internal/ticTacToe/move" + "github.com/talgat-ruby/exercises-go/exercise4/judge/internal/ticTacToe/player" +) + +func (r *Round) Start(ctx context.Context, num int) { + r.startPrint(num) + + for i := 0; ; i++ { + newMove := move.New(r.Players[i%len(r.Players)], r.Board) + r.Moves = append(r.Moves, newMove) + + ind, err := newMove.Start(ctx) + if err != nil { + slog.ErrorContext(ctx, "invalid move", "error", err) + r.Winner = r.Players[(i+1)%len(r.Players)] + break + } + + if winnerToken := r.Board.Evaluate(ind, *newMove.Player.Token); winnerToken != nil { + r.Winner = r.findWinnerByToken(*winnerToken) + break + } + } + + r.endPrint(num) +} + +func (r *Round) findWinnerByToken(t internal.Token) *player.Player { + for _, p := range r.Players { + if *p.Token == t { + return p + } + } + + return nil +} + +func (r *Round) startPrint(num int) { + fmt.Printf( + ` +Round #%d: %s(%s) vs %s(%s) +`, + num, + r.Players[0].Name, + *r.Players[0].Token, + r.Players[1].Name, + *r.Players[1].Token, + ) + <-time.After(time.Second) +} + +func (r *Round) endPrint(num int) { + if r.Winner == nil { + fmt.Printf( + ` +Round #%d: is draw +`, + num, + ) + <-time.After(time.Second) + return + } + + fmt.Printf( + ` +Round #%d: %s(%s) won! +`, + num, + r.Winner.Name, + *r.Winner.Token, + ) + <-time.After(time.Second) +} diff --git a/exercise4/judge/main.go b/exercise4/judge/main.go new file mode 100644 index 00000000..1df66255 --- /dev/null +++ b/exercise4/judge/main.go @@ -0,0 +1,44 @@ +package main + +import ( + "context" + "log/slog" + "os" + "os/signal" + + "github.com/talgat-ruby/exercises-go/exercise4/judge/internal/api" +) + +func main() { + ctx, cancel := context.WithCancel(context.Background()) + + port := os.Getenv("PORT") + + a := api.New() + + if err := a.Start(ctx, port); err != nil { + slog.ErrorContext(ctx, "api start error", "error", err) + os.Exit(1) + } + + 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() + }() + + <-ctx.Done() + // Your cleanup tasks go here + slog.InfoContext(ctx, "cleaning up ...") + + // Close api service + 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/exercise4/judge/pkg/httputils/request/body.go b/exercise4/judge/pkg/httputils/request/body.go new file mode 100644 index 00000000..e0a42101 --- /dev/null +++ b/exercise4/judge/pkg/httputils/request/body.go @@ -0,0 +1,76 @@ +package request + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/talgat-ruby/exercises-go/exercise4/judge/pkg/httputils/statusError" +) + +func JSON(w http.ResponseWriter, r *http.Request, dst interface{}) error { + ct := r.Header.Get("Content-Type") + if ct != "" { + mediaType := strings.ToLower(strings.TrimSpace(strings.Split(ct, ";")[0])) + if mediaType != "application/json" { + msg := "Content-Type header is not application/json" + return statusError.New(http.StatusUnsupportedMediaType, msg) + } + } + + r.Body = http.MaxBytesReader(w, r.Body, 1048576) + + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + + err := dec.Decode(&dst) + if err != nil { + var syntaxError *json.SyntaxError + var unmarshalTypeError *json.UnmarshalTypeError + + switch { + case errors.As(err, &syntaxError): + msg := fmt.Sprintf("Request body contains badly-formed JSON (at position %d)", syntaxError.Offset) + return statusError.New(http.StatusBadRequest, msg) + + case errors.Is(err, io.ErrUnexpectedEOF): + msg := "Request body contains badly-formed JSON" + return statusError.New(http.StatusBadRequest, msg) + + case errors.As(err, &unmarshalTypeError): + msg := fmt.Sprintf( + "Request body contains an invalid value for the %q field (at position %d)", + unmarshalTypeError.Field, + unmarshalTypeError.Offset, + ) + return statusError.New(http.StatusBadRequest, msg) + + case strings.HasPrefix(err.Error(), "json: unknown field "): + fieldName := strings.TrimPrefix(err.Error(), "json: unknown field ") + msg := fmt.Sprintf("Request body contains unknown field %s", fieldName) + return statusError.New(http.StatusBadRequest, msg) + + case errors.Is(err, io.EOF): + msg := "Request body must not be empty" + return statusError.New(http.StatusBadRequest, msg) + + case err.Error() == "http: request body too large": + msg := "Request body must not be larger than 1MB" + return statusError.New(http.StatusRequestEntityTooLarge, msg) + + default: + return err + } + } + + err = dec.Decode(&struct{}{}) + if !errors.Is(err, io.EOF) { + msg := "Request body must only contain a single JSON object" + return statusError.New(http.StatusBadRequest, msg) + } + + return nil +} diff --git a/exercise4/judge/pkg/httputils/response/body.go b/exercise4/judge/pkg/httputils/response/body.go new file mode 100644 index 00000000..e1fd78a8 --- /dev/null +++ b/exercise4/judge/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/exercise4/judge/pkg/httputils/statusError/main.go b/exercise4/judge/pkg/httputils/statusError/main.go new file mode 100644 index 00000000..6cf4e1b6 --- /dev/null +++ b/exercise4/judge/pkg/httputils/statusError/main.go @@ -0,0 +1,18 @@ +package statusError + +type StatusError struct { + status int + msg string +} + +func New(status int, msg string) error { + return &StatusError{status, msg} +} + +func (st *StatusError) Error() string { + return st.msg +} + +func (st *StatusError) Status() int { + return st.status +} diff --git a/exercise4/judge/pkg/logger/logger.go b/exercise4/judge/pkg/logger/logger.go new file mode 100644 index 00000000..d4631c3a --- /dev/null +++ b/exercise4/judge/pkg/logger/logger.go @@ -0,0 +1,20 @@ +package logger + +import ( + "log/slog" + "os" +) + +func New(isJson bool) *slog.Logger { + if isJson { + return slog.New(slog.NewJSONHandler(os.Stdout, nil)) + } + + return slog.New( + slog.NewTextHandler( + os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelDebug, + }, + ), + ) +} diff --git a/exercise5.zip b/exercise5.zip new file mode 100644 index 00000000..6a40a69f Binary files /dev/null and b/exercise5.zip differ diff --git a/exercise5/README.md b/exercise5/README.md new file mode 100644 index 00000000..1895394c --- /dev/null +++ b/exercise5/README.md @@ -0,0 +1,12 @@ +# Exercise 5 + +Please provide solution for the following problems * is mandatory + +1. [problem1](./problem1/README.md) * +2. [problem2](./problem2/README.md) +3. [problem3](./problem3/README.md) * +4. [problem4](./problem4/README.md) * +5. [problem5](./problem5/README.md) * +6. [problem6](./problem6/README.md) +7. [problem7](./problem7/README.md) * +8. [problem8](./problem8/README.md) * diff --git a/exercise5/problem1/README.md b/exercise5/problem1/README.md new file mode 100644 index 00000000..68cf3890 --- /dev/null +++ b/exercise5/problem1/README.md @@ -0,0 +1,3 @@ +# Problem 1 + +`incrementConcurrently` was implemented incorrectly, please update with minimum changes diff --git a/exercise5/problem1/problem1.go b/exercise5/problem1/problem1.go new file mode 100644 index 00000000..5ceb3f21 --- /dev/null +++ b/exercise5/problem1/problem1.go @@ -0,0 +1,14 @@ +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/problem1/problem1_test.go b/exercise5/problem1/problem1_test.go new file mode 100644 index 00000000..2a43bd54 --- /dev/null +++ b/exercise5/problem1/problem1_test.go @@ -0,0 +1,14 @@ +package problem1 + +import ( + "testing" +) + +func TestIncrementConcurrently(t *testing.T) { + inp := 2 + exp := 3 + out := incrementConcurrently(inp) + if out != exp { + t.Errorf("incrementConcurrently(%d) was incorrect, got: %d, expected: %d.", inp, out, exp) + } +} diff --git a/exercise5/problem2/README.md b/exercise5/problem2/README.md new file mode 100644 index 00000000..62a4b087 --- /dev/null +++ b/exercise5/problem2/README.md @@ -0,0 +1,26 @@ +# Problem 2 + +`addConcurrently` needs to be implemented concurrently: + +1. Utilize all cores on machine (Advanced concept) +2. Divide the input into parts +3. Run computation for each part in separate goroutine. + +This is an optional problem which involves `runtime` package and includes benchmark tests. +Because of nature of benchmark tests, I can only share my results: + +```shell +$ go test -v -bench=. -race ./exercise4/problem2/... +=== RUN TestAdd +--- PASS: TestAdd (0.10s) +goos: darwin +goarch: arm64 +pkg: github.com/talgat-ruby/exercises-go/exercise4/problem2 +BenchmarkAdd +BenchmarkAdd-8 20 55690504 ns/op +BenchmarkAddConcurrent +BenchmarkAddConcurrent-8 45 25124303 ns/op +PASS +ok github.com/talgat-ruby/exercises-go/exercise4/problem2 6.019s +``` + diff --git a/exercise5/problem2/problem2.go b/exercise5/problem2/problem2.go new file mode 100644 index 00000000..b37476cc --- /dev/null +++ b/exercise5/problem2/problem2.go @@ -0,0 +1,47 @@ +// File: problem2.go +package problem2 + +import ( + "runtime" + "sync" +) + +// add - sequential code to add numbers, don't update it, just to illustrate concept +func add(numbers []int) int64 { + var sum int64 + for _, n := range numbers { + sum += int64(n) + } + return sum +} + +func addConcurrently(numbers []int) int64 { + numCores := runtime.NumCPU() + chunkSize := (len(numbers) + numCores - 1) / numCores + var sum int64 + var wg sync.WaitGroup + mu := &sync.Mutex{} + + wg.Add(numCores) + for i := 0; i < numCores; i++ { + start := i * chunkSize + end := start + chunkSize + if end > len(numbers) { + end = len(numbers) + } + + go func(start, end int) { + defer wg.Done() + var localSum int64 + for _, n := range numbers[start:end] { + localSum += int64(n) + } + mu.Lock() + sum += localSum + mu.Unlock() + }(start, end) + } + + wg.Wait() + return sum +} diff --git a/exercise5/problem2/problem2_test.go b/exercise5/problem2/problem2_test.go new file mode 100644 index 00000000..370c87c3 --- /dev/null +++ b/exercise5/problem2/problem2_test.go @@ -0,0 +1,51 @@ +package problem2 + +import ( + "math/rand" + "os" + "testing" + "time" + + "github.com/talgat-ruby/exercises-go/pkg/util" +) + +var numbers []int + +func TestMain(m *testing.M) { + numbers = generateNumbers(1e7) + code := m.Run() + numbers = []int{} + os.Exit(code) +} + +func generateNumbers(max int) []int { + r := rand.New(rand.NewSource(time.Now().UnixNano())) + nums := make([]int, max) + for i := range nums { + nums[i] = r.Intn(10) + } + return nums +} + +func BenchmarkAdd(b *testing.B) { + for i := 0; i < b.N; i++ { + add(numbers) + } +} + +func BenchmarkAddConcurrent(b *testing.B) { + for i := 0; i < b.N; i++ { + addConcurrently(numbers) + } +} + +func TestAdd(t *testing.T) { + util.SkipTestOptional(t) + + inp := numbers + exp := add(inp) + out := addConcurrently(inp) + if out != exp { + t.Errorf("addConcurrently() was incorrect, got: %d, expected: %d.", out, exp) + } +} diff --git a/exercise5/problem3/README.md b/exercise5/problem3/README.md new file mode 100644 index 00000000..cffb9150 --- /dev/null +++ b/exercise5/problem3/README.md @@ -0,0 +1,3 @@ +# Problem 3 + +`sum` was implemented incorrectly, please update with minimum changes. Use **channels**, not **waitGroups**. diff --git a/exercise5/problem3/problem3.go b/exercise5/problem3/problem3.go new file mode 100644 index 00000000..97111869 --- /dev/null +++ b/exercise5/problem3/problem3.go @@ -0,0 +1,9 @@ +package problem3 + +func sum(a, b int) int { + resultChan := make(chan int) + go func(a, b int) { + resultChan <- a + b + }(a, b) + return <-resultChan +} diff --git a/exercise5/problem3/problem3_test.go b/exercise5/problem3/problem3_test.go new file mode 100644 index 00000000..b9552720 --- /dev/null +++ b/exercise5/problem3/problem3_test.go @@ -0,0 +1,27 @@ +package problem3 + +import ( + "testing" +) + +func TestSum(t *testing.T) { + table := []struct { + a, b int + exp int + }{ + {2, 100, 102}, + {0, 0, 0}, + {30, 40, 70}, + {10, 50, 60}, + {1, 219, 220}, + {25, 219, 244}, + {1, 0, 1}, + } + + for _, r := range table { + out := sum(r.a, r.b) + if out != r.exp { + t.Errorf("sum(%d, %d) was incorrect, got: %v, expected: %v.", r.a, r.b, out, r.exp) + } + } +} diff --git a/exercise5/problem4/README.md b/exercise5/problem4/README.md new file mode 100644 index 00000000..5d761bb0 --- /dev/null +++ b/exercise5/problem4/README.md @@ -0,0 +1,3 @@ +# Problem 4 + +`sum` was implemented incorrectly, please update with minimum changes. diff --git a/exercise5/problem4/problem4.go b/exercise5/problem4/problem4.go new file mode 100644 index 00000000..7e30f42e --- /dev/null +++ b/exercise5/problem4/problem4.go @@ -0,0 +1,19 @@ +package problem4 + +func iter(ch chan<- int, nums []int) { + for _, n := range nums { + ch <- n + } + close(ch) // Close the channel after sending all numbers +} + +func sum(nums []int) int { + ch := make(chan int) + go iter(ch, nums) + + var sum int + for n := range ch { + sum += n + } + return sum +} diff --git a/exercise5/problem4/problem4_test.go b/exercise5/problem4/problem4_test.go new file mode 100644 index 00000000..22e48f6c --- /dev/null +++ b/exercise5/problem4/problem4_test.go @@ -0,0 +1,24 @@ +package problem4 + +import ( + "testing" +) + +func TestSum(t *testing.T) { + table := []struct { + nums []int + exp int + }{ + {nums: []int{3, 2, 1, 0}, exp: 6}, + {nums: []int{4, 3, 2, 1}, exp: 10}, + {nums: []int{5, 4, 3, 2}, exp: 14}, + {nums: []int{6, 5, 4, 3}, exp: 18}, + } + + for _, r := range table { + out := sum(r.nums) + if out != r.exp { + t.Errorf("sum(%v) was incorrect, got: %v, expected: %v.", r.nums, out, r.exp) + } + } +} diff --git a/exercise5/problem5/README.md b/exercise5/problem5/README.md new file mode 100644 index 00000000..1b064ece --- /dev/null +++ b/exercise5/problem5/README.md @@ -0,0 +1,21 @@ +# Problem 5 + +`send` combines words into a single message. It accepts slice of strings which needs to combine, producer and consumer. + +Your job is to implement correct `producer` and `consumer`. + +```go +list := []string{ +"Hello", +"dear", +"friend!", +"Learn", +"from", +"yesterday.", +"Save", +"our", +"soles.", +} + +send(list, producer, consumer) // "Hello dear friend! Learn from yesterday. Save our soles." +``` diff --git a/exercise5/problem5/problem5.go b/exercise5/problem5/problem5.go new file mode 100644 index 00000000..94666e85 --- /dev/null +++ b/exercise5/problem5/problem5.go @@ -0,0 +1,25 @@ +package problem5 + +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 { + if message != "" { + message += " " + } + message += word + } + return message +} + +func send(words []string, pr func([]string, chan<- string), cons func(<-chan string) string) string { + ch := make(chan string) + go pr(words, ch) + return cons(ch) +} diff --git a/exercise5/problem5/problem5_test.go b/exercise5/problem5/problem5_test.go new file mode 100644 index 00000000..6c4e837a --- /dev/null +++ b/exercise5/problem5/problem5_test.go @@ -0,0 +1,57 @@ +package problem5 + +import ( + "testing" +) + +func TestSend(t *testing.T) { + table := []struct { + list []string + exp string + }{ + { + list: []string{ + "Hello", + "dear", + "friend!", + "Learn", + "from", + "yesterday.", + "Save", + "our", + "soles.", + }, + exp: "Hello dear friend! Learn from yesterday. Save our soles.", + }, + { + list: []string{ + "Frankly,", + "my", + "dear,", + "I", + "don’t", + "give", + "a", + "damn.", + }, + exp: "Frankly, my dear, I don’t give a damn.", + }, + { + list: []string{ + "Houston,", + "we", + "have", + "a", + "problem.", + }, + exp: "Houston, we have a problem.", + }, + } + + for _, r := range table { + out := send(r.list, producer, consumer) + if out != r.exp { + t.Errorf("sum(%v) was incorrect, got: %v, expected: %v.", r.list, out, r.exp) + } + } +} diff --git a/exercise5/problem6/README.md b/exercise5/problem6/README.md new file mode 100644 index 00000000..eabca047 --- /dev/null +++ b/exercise5/problem6/README.md @@ -0,0 +1,3 @@ +# Problem 6 + +`piper` combines several operations(pipes). Your job with is to implement `piper` and the pipes. Please check tests diff --git a/exercise5/problem6/problem6.go b/exercise5/problem6/problem6.go new file mode 100644 index 00000000..36bdee8d --- /dev/null +++ b/exercise5/problem6/problem6.go @@ -0,0 +1,33 @@ +package problem6 + +type pipe func(in <-chan int) <-chan int + +var multiplyBy2 pipe = func(in <-chan int) <-chan int { + out := make(chan int) + go func() { + defer close(out) + for num := range in { + out <- num * 2 + } + }() + return out +} + +var add5 pipe = func(in <-chan int) <-chan int { + out := make(chan int) + go func() { + defer close(out) + for num := range in { + out <- num + 5 + } + }() + return out +} + +func piper(in <-chan int, pipes []pipe) <-chan int { + out := in + for _, p := range pipes { + out = p(out) + } + return out +} diff --git a/exercise5/problem6/problem6_test.go b/exercise5/problem6/problem6_test.go new file mode 100644 index 00000000..5aea135c --- /dev/null +++ b/exercise5/problem6/problem6_test.go @@ -0,0 +1,56 @@ +package problem6 + +import ( + "slices" + "testing" + + "github.com/talgat-ruby/exercises-go/pkg/util" +) + +func sendNums(out chan<- int, nums []int) { + defer close(out) + for _, n := range nums { + out <- n + } +} + +func receiveResults(in <-chan int) []int { + nums := make([]int, 0) + for n := range in { + nums = append(nums, n) + } + return nums +} + +func TestPiper(t *testing.T) { + util.SkipTestOptional(t) + + table := []struct { + nums []int + exp []int + }{ + { + []int{1, 2, 3}, + []int{7, 9, 11}, + }, + { + []int{2, 3, 4}, + []int{9, 11, 13}, + }, + { + []int{10, 1, 6}, + []int{25, 7, 17}, + }, + } + + for _, r := range table { + in := make(chan int) + out := piper(in, []pipe{multiplyBy2, add5}) + go sendNums(in, r.nums) + results := receiveResults(out) + + if !slices.Equal(results, r.exp) { + t.Errorf("piper() was incorrect, got: %v, expected: %v.", results, r.exp) + } + } +} diff --git a/exercise5/problem7/README.md b/exercise5/problem7/README.md new file mode 100644 index 00000000..fc04c43c --- /dev/null +++ b/exercise5/problem7/README.md @@ -0,0 +1,3 @@ +# Problem 7 + +`multiplex` receives values from channels and returns slice of values sorted by which appeared first. diff --git a/exercise5/problem7/problem7.go b/exercise5/problem7/problem7.go new file mode 100644 index 00000000..b3f12a2a --- /dev/null +++ b/exercise5/problem7/problem7.go @@ -0,0 +1,22 @@ +package problem7 + +func multiplex(ch1 <-chan string, ch2 <-chan string) []string { + var result []string + for ch1 != nil || ch2 != nil { + select { + case msg, ok := <-ch1: + if ok { + result = append(result, msg) + } else { + ch1 = nil // Mark channel as closed + } + case msg, ok := <-ch2: + if ok { + result = append(result, msg) + } else { + ch2 = nil // Mark channel as closed + } + } + } + return result +} diff --git a/exercise5/problem7/problem7_test.go b/exercise5/problem7/problem7_test.go new file mode 100644 index 00000000..4e2b0864 --- /dev/null +++ b/exercise5/problem7/problem7_test.go @@ -0,0 +1,64 @@ +package problem7 + +import ( + "slices" + "testing" + "time" +) + +func TestMultiplex(t *testing.T) { + t.Run( + "Single value", func(t *testing.T) { + exp := []string{"one", "two"} + ch1 := make(chan string) + ch2 := make(chan string) + + go func() { + defer close(ch1) + time.Sleep(1 * time.Second) + ch1 <- exp[0] + }() + + go func() { + defer close(ch2) + time.Sleep(2 * time.Second) + ch2 <- exp[1] + }() + + out := multiplex(ch1, ch2) + + if !slices.Equal(out, exp) { + t.Errorf("multiplex() was incorrect, got: %v, expected: %v.", out, exp) + } + }, + ) + + t.Run( + "Multiple values", func(t *testing.T) { + exp := []string{"one", "two", "three", "four"} + ch1 := make(chan string) + ch2 := make(chan string) + + go func() { + defer close(ch1) + time.Sleep(1 * time.Second) + ch1 <- exp[0] + time.Sleep(500 * time.Millisecond) + ch1 <- exp[1] + }() + + go func() { + defer close(ch2) + time.Sleep(2 * time.Second) + ch2 <- exp[2] + ch2 <- exp[3] + }() + + out := multiplex(ch1, ch2) + + if !slices.Equal(out, exp) { + t.Errorf("multiplex() was incorrect, got: %v, expected: %v.", out, exp) + } + }, + ) +} diff --git a/exercise5/problem8/README.md b/exercise5/problem8/README.md new file mode 100644 index 00000000..608502c0 --- /dev/null +++ b/exercise5/problem8/README.md @@ -0,0 +1,4 @@ +# Problem 8 + +`withTimeout` receives a channel and ttl(time to live). If a channel receives message before ttl, send the message, +otherwise send "timeout". diff --git a/exercise5/problem8/problem8.go b/exercise5/problem8/problem8.go new file mode 100644 index 00000000..999a0d0b --- /dev/null +++ b/exercise5/problem8/problem8.go @@ -0,0 +1,17 @@ +package problem8 + +import ( + "time" +) + +func withTimeout(ch <-chan string, ttl time.Duration) string { + timer := time.NewTimer(ttl) + defer timer.Stop() + + select { + case msg := <-ch: + return msg + case <-timer.C: + return "timeout" + } +} diff --git a/exercise5/problem8/problem8_test.go b/exercise5/problem8/problem8_test.go new file mode 100644 index 00000000..57b646f1 --- /dev/null +++ b/exercise5/problem8/problem8_test.go @@ -0,0 +1,47 @@ +package problem8 + +import ( + "testing" + "time" +) + +func TestWithTimeout(t *testing.T) { + table := []struct { + message string + duration time.Duration + ttl time.Duration + exp string + }{ + { + message: "success", + duration: time.Second, + ttl: 2 * time.Second, + exp: "success", + }, + { + message: "success", + duration: 3 * time.Second, + ttl: 2 * time.Second, + exp: "timeout", + }, + { + message: "fail", + duration: 3 * time.Second, + ttl: 5 * time.Second, + exp: "fail", + }, + } + + for _, r := range table { + ch := make(chan string) + go func() { + time.Sleep(r.duration) + ch <- r.message + }() + out := withTimeout(ch, r.ttl) + + if out != r.exp { + t.Errorf("withTimeout(ch, %v) was incorrect, got: %s, expected: %s.", r.ttl, out, r.exp) + } + } +} diff --git a/exercise6/README.md b/exercise6/README.md new file mode 100644 index 00000000..ead8802a --- /dev/null +++ b/exercise6/README.md @@ -0,0 +1,12 @@ +# Exercise 6 + +Please provide solution for the following problems * is mandatory + +1. [problem1](./problem1/README.md) * +2. [problem2](./problem2/README.md) +3. [problem3](./problem3/README.md) * +4. [problem4](./problem4/README.md) * +5. [problem5](./problem5/README.md) * +6. [problem6](./problem6/README.md) +7. [problem7](./problem7/README.md) * +8. [problem8](./problem8/README.md) * diff --git a/exercise6/problem1/README.md b/exercise6/problem1/README.md new file mode 100644 index 00000000..8aab39e4 --- /dev/null +++ b/exercise6/problem1/README.md @@ -0,0 +1,3 @@ +# Problem 1 + +Implement `bankAccount` with required concurrently methods, check tests. Use mutex. diff --git a/exercise6/problem1/problem1.go b/exercise6/problem1/problem1.go new file mode 100644 index 00000000..ee453b24 --- /dev/null +++ b/exercise6/problem1/problem1.go @@ -0,0 +1,9 @@ +package problem1 + +type bankAccount struct { + blnc int +} + +func newAccount(blnc int) *bankAccount { + return &bankAccount{blnc} +} diff --git a/exercise6/problem1/problem1_test.go b/exercise6/problem1/problem1_test.go new file mode 100644 index 00000000..293b681f --- /dev/null +++ b/exercise6/problem1/problem1_test.go @@ -0,0 +1,63 @@ +package problem1 + +import ( + "sync" + "testing" +) + +func TestBankAccount(t *testing.T) { + t.Run( + "cannot withdraw more than balance", func(t *testing.T) { + exp := 3 + var wg sync.WaitGroup + acc := newAccount(43) + + n := 100 + + wg.Add(n) + for i := range n { + go func(i int) { + defer wg.Done() + acc.withdraw(10) + }(i) + } + + wg.Wait() + + if acc.blnc != exp { + t.Errorf("balance was incorrect, expected: %d, got: %d.", exp, acc.blnc) + } + }, + ) + + t.Run( + "concurrently modify balance", func(t *testing.T) { + var wg sync.WaitGroup + acc := newAccount(0) + + depositNum := 200 + wg.Add(depositNum) + for i := range depositNum { + go func(i int) { + defer wg.Done() + acc.deposit(1) + }(i) + } + + withdrawNum := 100 + wg.Add(withdrawNum) + for i := range withdrawNum { + go func(i int) { + defer wg.Done() + acc.withdraw(1) + }(i) + } + + wg.Wait() + + if acc.blnc != depositNum-withdrawNum { + t.Errorf("balance was incorrect, expected: %d, got: %d.", depositNum-withdrawNum, acc.blnc) + } + }, + ) +} diff --git a/exercise6/problem2/README.md b/exercise6/problem2/README.md new file mode 100644 index 00000000..997f60c2 --- /dev/null +++ b/exercise6/problem2/README.md @@ -0,0 +1,3 @@ +# Problem 2 + +Use `bankAccount` from prev exercise and implement correctly `balance` method. Use mutex. diff --git a/exercise6/problem2/problem2.go b/exercise6/problem2/problem2.go new file mode 100644 index 00000000..97e02368 --- /dev/null +++ b/exercise6/problem2/problem2.go @@ -0,0 +1,20 @@ +package problem2 + +import ( + "time" +) + +var readDelay = 10 * time.Millisecond + +type bankAccount struct { + blnc int +} + +func newAccount(blnc int) *bankAccount { + return &bankAccount{blnc} +} + +func (b *bankAccount) balance() int { + time.Sleep(readDelay) + return 0 +} diff --git a/exercise6/problem2/problem2_test.go b/exercise6/problem2/problem2_test.go new file mode 100644 index 00000000..86654edb --- /dev/null +++ b/exercise6/problem2/problem2_test.go @@ -0,0 +1,103 @@ +package problem2 + +import ( + "sync" + "testing" + "time" +) + +func TestBankAccount(t *testing.T) { + t.Run( + "cannot withdraw more than balance", func(t *testing.T) { + exp := 3 + var wg sync.WaitGroup + acc := newAccount(43) + + n := 100 + + wg.Add(n) + for i := range n { + go func(i int) { + defer wg.Done() + acc.withdraw(10) + }(i) + } + + wg.Wait() + + if acc.balance() != exp { + t.Errorf("balance was incorrect, expected: %d, got: %d.", exp, acc.balance()) + } + }, + ) + + t.Run( + "concurrently modify balance", func(t *testing.T) { + var wg sync.WaitGroup + acc := newAccount(0) + + depositNum := 200 + wg.Add(depositNum) + for i := range depositNum { + go func(i int) { + defer wg.Done() + acc.deposit(1) + }(i) + } + + withdrawNum := 100 + wg.Add(withdrawNum) + for i := range withdrawNum { + go func(i int) { + defer wg.Done() + acc.withdraw(1) + }(i) + } + + wg.Wait() + + if acc.balance() != depositNum-withdrawNum { + t.Errorf("balance was incorrect, expected: %d, got: %d.", depositNum-withdrawNum, acc.balance()) + } + }, + ) + + t.Run( + "concurrently read balance", func(t *testing.T) { + var wg sync.WaitGroup + acc := newAccount(0) + + readNum := 100 + + totalDelay := time.Duration(readNum+10) * readDelay + timer := time.AfterFunc( + totalDelay, func() { + t.Error("too long operation, make it faster please") + }, + ) + + wg.Add(readNum) + go func() { + for range readNum { + go func() { + defer wg.Done() + acc.balance() + }() + } + }() + + wg.Add(readNum) + go func() { + for range readNum { + go func() { + defer wg.Done() + acc.balance() + }() + } + }() + + wg.Wait() + timer.Stop() + }, + ) +} diff --git a/exercise6/problem3/README.md b/exercise6/problem3/README.md new file mode 100644 index 00000000..c9199f40 --- /dev/null +++ b/exercise6/problem3/README.md @@ -0,0 +1,3 @@ +# Problem 3 + +Create concurrently correct `counter`. Check the tests. Use `sync/atomic`. diff --git a/exercise6/problem3/problem3.go b/exercise6/problem3/problem3.go new file mode 100644 index 00000000..b34b90bb --- /dev/null +++ b/exercise6/problem3/problem3.go @@ -0,0 +1,11 @@ +package problem3 + +type counter struct { + val int64 +} + +func newCounter() *counter { + return &counter{ + val: 0, + } +} diff --git a/exercise6/problem3/problem3_test.go b/exercise6/problem3/problem3_test.go new file mode 100644 index 00000000..d6bc75ba --- /dev/null +++ b/exercise6/problem3/problem3_test.go @@ -0,0 +1,44 @@ +package problem3 + +import ( + "sync" + "testing" +) + +func TestCounter(t *testing.T) { + t.Run( + "concurrently increment & decrement counter", func(t *testing.T) { + cntr := newCounter() + var wg sync.WaitGroup + + inc := 200 + wg.Add(inc) + go func() { + for range inc { + go func() { + defer wg.Done() + cntr.inc() + }() + } + }() + + dec := 100 + wg.Add(dec) + go func() { + for range dec { + go func() { + defer wg.Done() + cntr.dec() + }() + } + }() + + wg.Wait() + + out := int64(inc - dec) + if cntr.value() != out { + t.Errorf("counter value was incorrect, expected: %d, got: %d.", out, cntr.value()) + } + }, + ) +} diff --git a/exercise6/problem4/README.md b/exercise6/problem4/README.md new file mode 100644 index 00000000..b7b27dee --- /dev/null +++ b/exercise6/problem4/README.md @@ -0,0 +1,5 @@ +# Problem 4 + +We have a several workers waiting for shopping list to fill. When shopping list filled **first** in a queue worker +should notify. Please update the code so the `notifier` will send **first** workers id when `shoppingList` is ready. Do +not use channels. diff --git a/exercise6/problem4/problem4.go b/exercise6/problem4/problem4.go new file mode 100644 index 00000000..793449c9 --- /dev/null +++ b/exercise6/problem4/problem4.go @@ -0,0 +1,31 @@ +package problem4 + +import ( + "time" +) + +func worker(id int, _ *[]string, ch chan<- int) { + // TODO wait for shopping list to be completed + ch <- id +} + +func updateShopList(shoppingList *[]string) { + time.Sleep(10 * time.Millisecond) + + *shoppingList = append(*shoppingList, "apples") + *shoppingList = append(*shoppingList, "milk") + *shoppingList = append(*shoppingList, "bake soda") +} + +func notifyOnShopListUpdate(shoppingList *[]string, numWorkers int) <-chan int { + notifier := make(chan int) + + for i := range numWorkers { + go worker(i+1, shoppingList, notifier) + time.Sleep(time.Millisecond) // order matters + } + + go updateShopList(shoppingList) + + return notifier +} diff --git a/exercise6/problem4/problem4_test.go b/exercise6/problem4/problem4_test.go new file mode 100644 index 00000000..500c4015 --- /dev/null +++ b/exercise6/problem4/problem4_test.go @@ -0,0 +1,40 @@ +package problem4 + +import ( + "slices" + "testing" + "time" +) + +func notifiedWorkers(notifier <-chan int) []int { + workersId := make([]int, 0, 1) + + for { + select { + case workerId := <-notifier: + workersId = append(workersId, workerId) + case <-time.After(time.Second): + return workersId + } + } +} + +func TestShoppingListSingleWorker(t *testing.T) { + t.Run( + "only one worker should notify", func(t *testing.T) { + exp := []int{1} + + shoppingList := &[]string{} + numWorkers := 10 + notifier := notifyOnShopListUpdate(shoppingList, numWorkers) + out := notifiedWorkers(notifier) + + if !slices.Equal(out, exp) { + t.Errorf("notification channel has wrong job, expected: %v, got: %v.", exp, out) + } + if len(*shoppingList) == 0 { + t.Error("shopping list is empty, you need to wait until it is completed") + } + }, + ) +} diff --git a/exercise6/problem5/README.md b/exercise6/problem5/README.md new file mode 100644 index 00000000..9454cedf --- /dev/null +++ b/exercise6/problem5/README.md @@ -0,0 +1,5 @@ +# Problem 5 + +We have a several workers waiting for shopping list to fill. When shopping list filled **all** in a queue workers +should notify. Please update the code so the `notifier` will send **all** workers id when `shoppingList` is ready. Do +not use channels. diff --git a/exercise6/problem5/problem5.go b/exercise6/problem5/problem5.go new file mode 100644 index 00000000..8e4a1703 --- /dev/null +++ b/exercise6/problem5/problem5.go @@ -0,0 +1,31 @@ +package problem5 + +import ( + "time" +) + +func worker(id int, shoppingList *[]string, ch chan<- int) { + // TODO wait for shopping list to be completed + ch <- id +} + +func updateShopList(shoppingList *[]string) { + time.Sleep(10 * time.Millisecond) + + *shoppingList = append(*shoppingList, "apples") + *shoppingList = append(*shoppingList, "milk") + *shoppingList = append(*shoppingList, "bake soda") +} + +func notifyOnShopListUpdate(shoppingList *[]string, numWorkers int) <-chan int { + notifier := make(chan int) + + for i := range numWorkers { + go worker(i+1, shoppingList, notifier) + time.Sleep(time.Millisecond) // order matters + } + + go updateShopList(shoppingList) + + return notifier +} diff --git a/exercise6/problem5/problem5_test.go b/exercise6/problem5/problem5_test.go new file mode 100644 index 00000000..2fbff098 --- /dev/null +++ b/exercise6/problem5/problem5_test.go @@ -0,0 +1,42 @@ +package problem5 + +import ( + "slices" + "sort" + "testing" + "time" +) + +func notifiedWorkers(notifier <-chan int) []int { + workersId := make([]int, 0, 1) + + for { + select { + case workerId := <-notifier: + workersId = append(workersId, workerId) + case <-time.After(time.Second): + return workersId + } + } +} + +func TestShoppingListAllWorkers(t *testing.T) { + t.Run( + "only one worker should notify", func(t *testing.T) { + exp := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10} + + shoppingList := &[]string{} + numWorkers := 10 + notifier := notifyOnShopListUpdate(shoppingList, numWorkers) + out := notifiedWorkers(notifier) + sort.Ints(out) + + if !slices.Equal(out, exp) { + t.Errorf("notification channel has wrong job, expected: %v, got: %v.", exp, out) + } + if len(*shoppingList) == 0 { + t.Error("shopping list is empty, you need to wait until it is completed") + } + }, + ) +} diff --git a/exercise6/problem6/README.md b/exercise6/problem6/README.md new file mode 100644 index 00000000..c6f42bbf --- /dev/null +++ b/exercise6/problem6/README.md @@ -0,0 +1,3 @@ +# Problem 6 + +`runTasks` need to initialize some conf and we need to run it only once. diff --git a/exercise6/problem6/problem6.go b/exercise6/problem6/problem6.go new file mode 100644 index 00000000..0c1122b9 --- /dev/null +++ b/exercise6/problem6/problem6.go @@ -0,0 +1,20 @@ +package problem6 + +import ( + "sync" +) + +func runTasks(init func()) { + var wg sync.WaitGroup + + for range 10 { + wg.Add(1) + go func() { + defer wg.Done() + + //TODO: modify so that load function gets called only once. + init() + }() + } + wg.Wait() +} diff --git a/exercise6/problem6/problem6_test.go b/exercise6/problem6/problem6_test.go new file mode 100644 index 00000000..31c875cf --- /dev/null +++ b/exercise6/problem6/problem6_test.go @@ -0,0 +1,31 @@ +package problem6 + +import ( + "sync/atomic" + "testing" +) + +func initGen(counter *atomic.Int64) func() { + return func() { + counter.Add(1) + } +} + +func TestInitFunc(t *testing.T) { + t.Run( + "init should run one only once", func(t *testing.T) { + var exp int64 = 1 + + var counter atomic.Int64 + + init := initGen(&counter) + runTasks(init) + + out := counter.Load() + + if exp != out { + t.Errorf("init function run times wrong, expected: %v, got: %v.", exp, out) + } + }, + ) +} diff --git a/exercise6/problem7/README.md b/exercise6/problem7/README.md new file mode 100644 index 00000000..153480ec --- /dev/null +++ b/exercise6/problem7/README.md @@ -0,0 +1,5 @@ +# Problem 7 + +Identify the data race in `task`. Fix the issue. + +NOTE: don't forget to run test with `-race` flag diff --git a/exercise6/problem7/problem7.go b/exercise6/problem7/problem7.go new file mode 100644 index 00000000..ef49497b --- /dev/null +++ b/exercise6/problem7/problem7.go @@ -0,0 +1,24 @@ +package problem7 + +import ( + "fmt" + "math/rand" + "time" +) + +func task() { + start := time.Now() + var t *time.Timer + t = time.AfterFunc( + randomDuration(), + func() { + fmt.Println(time.Now().Sub(start)) + t.Reset(randomDuration()) + }, + ) + time.Sleep(5 * time.Second) +} + +func randomDuration() time.Duration { + return time.Duration(rand.Int63n(1e9)) +} diff --git a/exercise6/problem7/problem7_test.go b/exercise6/problem7/problem7_test.go new file mode 100644 index 00000000..8c62b7e7 --- /dev/null +++ b/exercise6/problem7/problem7_test.go @@ -0,0 +1,13 @@ +package problem7 + +import ( + "testing" +) + +func TestTask(t *testing.T) { + t.Run( + "should avoid data race", func(t *testing.T) { + task() + }, + ) +} diff --git a/exercise6/problem8/README.md b/exercise6/problem8/README.md new file mode 100644 index 00000000..1aa145ea --- /dev/null +++ b/exercise6/problem8/README.md @@ -0,0 +1,4 @@ +# Problem 8 + +This is extended version from [exercise5/problem7](../../exercise5/problem7/README.md). What if instead of receiving +just 2 channels, multiplexer will receive slice of channels. Do not use a mutex or an atomic. diff --git a/exercise6/problem8/problem8.go b/exercise6/problem8/problem8.go new file mode 100644 index 00000000..949eb2d2 --- /dev/null +++ b/exercise6/problem8/problem8.go @@ -0,0 +1,3 @@ +package problem8 + +func multiplex(chs []<-chan string) []string {} diff --git a/exercise6/problem8/problem8_test.go b/exercise6/problem8/problem8_test.go new file mode 100644 index 00000000..4ac11abd --- /dev/null +++ b/exercise6/problem8/problem8_test.go @@ -0,0 +1,71 @@ +package problem8 + +import ( + "slices" + "testing" + "time" +) + +func TestMultiplex(t *testing.T) { + t.Run( + "single value", func(t *testing.T) { + exp := []string{"one", "two", "three"} + ch1 := make(chan string) + ch2 := make(chan string) + ch3 := make(chan string) + + go func() { + defer close(ch1) + time.Sleep(1 * time.Second) + ch1 <- exp[0] + }() + + go func() { + defer close(ch2) + time.Sleep(2 * time.Second) + ch2 <- exp[1] + }() + + go func() { + defer close(ch3) + time.Sleep(3 * time.Second) + ch3 <- exp[2] + }() + + out := multiplex([]<-chan string{ch1, ch2, ch3}) + + if !slices.Equal(out, exp) { + t.Errorf("multiplex() was incorrect, got: %v, expected: %v.", out, exp) + } + }, + ) + + t.Run( + "multiple values", func(t *testing.T) { + exp := []string{"one", "two", "three", "four"} + ch1 := make(chan string) + ch2 := make(chan string) + + go func() { + defer close(ch1) + time.Sleep(1 * time.Second) + ch1 <- exp[0] + time.Sleep(500 * time.Millisecond) + ch1 <- exp[1] + }() + + go func() { + defer close(ch2) + time.Sleep(2 * time.Second) + ch2 <- exp[2] + ch2 <- exp[3] + }() + + out := multiplex([]<-chan string{ch1, ch2}) + + if !slices.Equal(out, exp) { + t.Errorf("multiplex() was incorrect, got: %v, expected: %v.", out, exp) + } + }, + ) +} diff --git a/exercise7/blogging-platform/Dockerfile b/exercise7/blogging-platform/Dockerfile new file mode 100644 index 00000000..413777b2 --- /dev/null +++ b/exercise7/blogging-platform/Dockerfile @@ -0,0 +1,15 @@ +FROM golang:1.23-alpine + +WORKDIR /app + +COPY go.mod go.sum ./ + +RUN go mod download + +COPY . . + +RUN go build -o main ./cmd/main.go + +EXPOSE 8080 + +CMD ["/app/main"] diff --git a/exercise7/blogging-platform/MakeFile b/exercise7/blogging-platform/MakeFile new file mode 100644 index 00000000..a6902b7a --- /dev/null +++ b/exercise7/blogging-platform/MakeFile @@ -0,0 +1,17 @@ +IMAGE_NAME = myblog:latest + +.PHONY: build +build: + go build -o bin/myblog ./cmd/main.go + +.PHONY: run +run: + go run ./cmd/main.go + +.PHONY: docker-build +docker-build: + docker build -t $(IMAGE_NAME) -f docker/Dockerfile . + +.PHONY: docker-run +docker-run: + docker run --name myblog_container -p 8080:8080 --rm $(IMAGE_NAME) diff --git a/exercise7/blogging-platform/README.md b/exercise7/blogging-platform/README.md new file mode 100644 index 00000000..24229636 --- /dev/null +++ b/exercise7/blogging-platform/README.md @@ -0,0 +1,62 @@ +# MyBlog + +Простое RESTful API для личной блог-платформы. +Реализован полный CRUD для постов, а также поиск по термину. + +## Структура проекта + +- **cmd/main.go**: Точка входа в приложение +- **controllers/**: HTTP-хендлеры (CRUD-операции) +- **database/**: Инициализация и миграция БД (в примере — SQLite) +- **models/**: Модели данных (структуры GORM) +- **repositories/**: Логика работы с БД (CRUD) +- **routes/**: Определение маршрутов и привязка к контроллерам +- **docker/Dockerfile**: Для сборки Docker-образа +- **Makefile**: Упрощённая сборка/запуск +- **go.mod** / **go.sum**: Go-модули + +## Запуск локально + +Убедитесь, что у вас установлен Go (>= 1.18). Затем: + +```bash +go mod tidy + +go run ./cmd/main.go +``` +или через Make +```bash +make run +make docker-build +make docker-run +``` + +## Примеры запросов + +``` +curl -X POST -H "Content-Type: application/json" \ + -d '{ + "title": "My First Blog Post", + "content": "Hello World!", + "category": "General", + "tags": ["Welcome","First"] + }' \ + http://localhost:8080/posts + +curl http://localhost:8080/posts + +curl "http://localhost:8080/posts?term=welcome" + +curl http://localhost:8080/posts/1 + +curl -X PUT -H "Content-Type: application/json" \ + -d '{ + "title": "My Updated Post", + "content": "Updated content!", + "category": "Updates", + "tags": ["Update","News"] + }' \ + http://localhost:8080/posts/1 + +curl -X DELETE http://localhost:8080/posts/1 +``` \ No newline at end of file diff --git a/exercise7/blogging-platform/cmd/main.go b/exercise7/blogging-platform/cmd/main.go new file mode 100644 index 00000000..23532720 --- /dev/null +++ b/exercise7/blogging-platform/cmd/main.go @@ -0,0 +1,18 @@ +package main + +import ( + "alinurmyrzakhanov/database" + "alinurmyrzakhanov/routes" + + "github.com/gin-gonic/gin" +) + +func main() { + database.InitDB() + + r := gin.Default() + + routes.SetupRoutes(r) + + r.Run(":8080") +} diff --git a/exercise7/blogging-platform/controllers/post_controller.go b/exercise7/blogging-platform/controllers/post_controller.go new file mode 100644 index 00000000..14a37140 --- /dev/null +++ b/exercise7/blogging-platform/controllers/post_controller.go @@ -0,0 +1,135 @@ +// controllers/post_controller.go +package controllers + +import ( + "net/http" + "strconv" + "time" + + "alinurmyrzakhanov/models" + "alinurmyrzakhanov/repositories" + + "github.com/gin-gonic/gin" +) + +func CreatePost(c *gin.Context) { + var input struct { + Title string `json:"title" binding:"required"` + Content string `json:"content" binding:"required"` + Category string `json:"category" binding:"required"` + Tags []string `json:"tags"` + } + + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + post := models.Post{ + Title: input.Title, + Content: input.Content, + Category: input.Category, + Tags: input.Tags, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + if err := repositories.CreatePost(&post); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Не удалось создать пост"}) + return + } + c.JSON(http.StatusCreated, post) +} + +func GetPost(c *gin.Context) { + idParam := c.Param("id") + id, err := strconv.Atoi(idParam) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Некорректный ID"}) + return + } + + post, err := repositories.GetPostByID(uint(id)) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Пост не найден"}) + return + } + + c.JSON(http.StatusOK, post) +} + +func GetAllPosts(c *gin.Context) { + term := c.Query("term") + if term != "" { + posts, err := repositories.SearchPostsByTerm(term) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Ошибка при поиске постов"}) + return + } + c.JSON(http.StatusOK, posts) + } else { + posts, err := repositories.GetAllPosts() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Ошибка при получении постов"}) + return + } + c.JSON(http.StatusOK, posts) + } +} + +func UpdatePost(c *gin.Context) { + idParam := c.Param("id") + id, err := strconv.Atoi(idParam) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Некорректный ID"}) + return + } + existingPost, err := repositories.GetPostByID(uint(id)) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Пост не найден"}) + return + } + + var input struct { + Title string `json:"title" binding:"required"` + Content string `json:"content" binding:"required"` + Category string `json:"category" binding:"required"` + Tags []string `json:"tags"` + } + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + existingPost.Title = input.Title + existingPost.Content = input.Content + existingPost.Category = input.Category + existingPost.Tags = input.Tags + existingPost.UpdatedAt = time.Now() + + if err := repositories.UpdatePost(existingPost); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Не удалось обновить пост"}) + return + } + + c.JSON(http.StatusOK, existingPost) +} + +func DeletePost(c *gin.Context) { + idParam := c.Param("id") + id, err := strconv.Atoi(idParam) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Некорректный ID"}) + return + } + + _, getErr := repositories.GetPostByID(uint(id)) + if getErr != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Пост не найден"}) + return + } + + if err := repositories.DeletePost(uint(id)); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Не удалось удалить пост"}) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/exercise7/blogging-platform/database/database.go b/exercise7/blogging-platform/database/database.go new file mode 100644 index 00000000..a20a2ea3 --- /dev/null +++ b/exercise7/blogging-platform/database/database.go @@ -0,0 +1,41 @@ +// database/database.go +package database + +import ( + "fmt" + "log" + "os" + "time" + + "alinurmyrzakhanov/models" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +var DB *gorm.DB + +func InitDB() { + dbPath := "myblog.db" + if path := os.Getenv("DB_PATH"); path != "" { + dbPath = path + } + + dsn := fmt.Sprintf("%s", dbPath) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + if err != nil { + log.Fatal("Не удалось подключиться к БД:", err) + } + + err = db.AutoMigrate(&models.Post{}) + if err != nil { + log.Fatal("Ошибка миграции:", err) + } + + sqlDB, _ := db.DB() + sqlDB.SetMaxIdleConns(10) + sqlDB.SetMaxOpenConns(100) + sqlDB.SetConnMaxLifetime(time.Hour) + + DB = db +} diff --git a/exercise7/blogging-platform/go.mod b/exercise7/blogging-platform/go.mod new file mode 100644 index 00000000..271b7488 --- /dev/null +++ b/exercise7/blogging-platform/go.mod @@ -0,0 +1,38 @@ +module alinurmyrzakhanov + +go 1.23.3 + +require ( + github.com/bytedance/sonic v1.12.8 // indirect + github.com/bytedance/sonic/loader v0.2.3 // indirect + github.com/cloudwego/base64x v0.1.5 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/gin-contrib/sse v1.0.0 // indirect + github.com/gin-gonic/gin v1.10.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.24.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.9 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.24 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + golang.org/x/arch v0.14.0 // indirect + golang.org/x/crypto v0.32.0 // indirect + golang.org/x/net v0.34.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.22.0 // indirect + google.golang.org/protobuf v1.36.5 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + gorm.io/driver/sqlite v1.5.7 // indirect + gorm.io/gorm v1.25.12 // indirect +) diff --git a/exercise7/blogging-platform/go.sum b/exercise7/blogging-platform/go.sum new file mode 100644 index 00000000..1ed1b599 --- /dev/null +++ b/exercise7/blogging-platform/go.sum @@ -0,0 +1,88 @@ +github.com/bytedance/sonic v1.12.8 h1:4xYRVRlXIgvSZ4e8iVTlMF5szgpXd4AfvuWgA8I8lgs= +github.com/bytedance/sonic v1.12.8/go.mod h1:uVvFidNmlt9+wa31S1urfwwthTWteBgG0hWuoKAXTx8= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/bytedance/sonic/loader v0.2.3 h1:yctD0Q3v2NOGfSWPLPvG2ggA2kV6TS6s4wioyEqssH0= +github.com/bytedance/sonic/loader v0.2.3/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= +github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E= +github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.24.0 h1:KHQckvo8G6hlWnrPX4NJJ+aBfWNAE/HH+qdL2cBpCmg= +github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= +github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= +github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +golang.org/x/arch v0.14.0 h1:z9JUEZWr8x4rR0OU6c4/4t6E6jOZ8/QBS2bBYBm4tx4= +golang.org/x/arch v0.14.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I= +gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4= +gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= +gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/exercise7/blogging-platform/models/post.go b/exercise7/blogging-platform/models/post.go new file mode 100644 index 00000000..9ad33042 --- /dev/null +++ b/exercise7/blogging-platform/models/post.go @@ -0,0 +1,16 @@ +package models + +import ( + "time" +) + +type Post struct { + ID uint `gorm:"primaryKey" json:"id"` + Title string `gorm:"not null" json:"title"` + Content string `gorm:"not null" json:"content"` + Category string `gorm:"not null" json:"category"` + Tags []string `gorm:"-" json:"tags"` + TagsRaw string `json:"-"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} diff --git a/exercise7/blogging-platform/repositories/post_repository.go b/exercise7/blogging-platform/repositories/post_repository.go new file mode 100644 index 00000000..0d18801b --- /dev/null +++ b/exercise7/blogging-platform/repositories/post_repository.go @@ -0,0 +1,88 @@ +// repositories/post_repository.go +package repositories + +import ( + "errors" + "strings" + + "alinurmyrzakhanov/database" + "alinurmyrzakhanov/models" +) + +func CreatePost(post *models.Post) error { + post.TagsRaw = strings.Join(post.Tags, ",") // массив тегов => строка + if err := database.DB.Create(post).Error; err != nil { + return err + } + return nil +} + +func GetPostByID(id uint) (*models.Post, error) { + var post models.Post + if err := database.DB.First(&post, id).Error; err != nil { + return nil, err + } + if post.TagsRaw != "" { + post.Tags = strings.Split(post.TagsRaw, ",") + } else { + post.Tags = []string{} + } + + return &post, nil +} + +func UpdatePost(post *models.Post) error { + post.TagsRaw = strings.Join(post.Tags, ",") + if err := database.DB.Save(post).Error; err != nil { + return err + } + return nil +} + +func DeletePost(id uint) error { + result := database.DB.Delete(&models.Post{}, id) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return errors.New("post not found") + } + return nil +} +func GetAllPosts() ([]models.Post, error) { + var posts []models.Post + if err := database.DB.Find(&posts).Error; err != nil { + return nil, err + } + + for i := range posts { + if posts[i].TagsRaw != "" { + posts[i].Tags = strings.Split(posts[i].TagsRaw, ",") + } else { + posts[i].Tags = []string{} + } + } + return posts, nil +} + +func SearchPostsByTerm(term string) ([]models.Post, error) { + var posts []models.Post + likeTerm := "%" + term + "%" + + if err := database.DB.Where( + "title LIKE ? OR content LIKE ? OR category LIKE ?", + likeTerm, likeTerm, likeTerm). + Find(&posts).Error; err != nil { + return nil, err + } + + for i := range posts { + if posts[i].TagsRaw != "" { + posts[i].Tags = strings.Split(posts[i].TagsRaw, ",") + } else { + posts[i].Tags = []string{} + } + } + + return posts, nil +} diff --git a/exercise7/blogging-platform/routes/routes.go b/exercise7/blogging-platform/routes/routes.go new file mode 100644 index 00000000..8b694dc8 --- /dev/null +++ b/exercise7/blogging-platform/routes/routes.go @@ -0,0 +1,19 @@ +// routes/routes.go +package routes + +import ( + "alinurmyrzakhanov/controllers" + + "github.com/gin-gonic/gin" +) + +func SetupRoutes(r *gin.Engine) { + postRoutes := r.Group("/posts") + { + postRoutes.POST("", controllers.CreatePost) + postRoutes.GET("", controllers.GetAllPosts) + postRoutes.GET("/:id", controllers.GetPost) + postRoutes.PUT("/:id", controllers.UpdatePost) + postRoutes.DELETE("/:id", controllers.DeletePost) + } +} diff --git a/exercise9/Dockerfile b/exercise9/Dockerfile new file mode 100644 index 00000000..0a6f5b26 --- /dev/null +++ b/exercise9/Dockerfile @@ -0,0 +1,14 @@ +FROM golang:1.23-alpine + +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN go build -o main ./cmd/main.go + +EXPOSE 8080 + +CMD ["/app/main"] diff --git a/exercise9/Makefile b/exercise9/Makefile new file mode 100644 index 00000000..bd7334e3 --- /dev/null +++ b/exercise9/Makefile @@ -0,0 +1,18 @@ +IMAGE_NAME = myworkout:latest +CONTAINER_NAME = myworkout_container + +.PHONY: build +build: + go build -o bin/myworkout ./cmd/main.go + +.PHONY: run +run: + go run ./cmd/main.go + +.PHONY: docker-build +docker-build: + docker build -t $(IMAGE_NAME) -f docker/Dockerfile . + +.PHONY: docker-run +docker-run: + docker run --name $(CONTAINER_NAME) -p 8080:8080 --rm $(IMAGE_NAME) diff --git a/exercise9/README.md b/exercise9/README.md new file mode 100644 index 00000000..3a4ca0c9 --- /dev/null +++ b/exercise9/README.md @@ -0,0 +1,94 @@ +# MyWorkout — Трекер тренировок + +Это бэкенд-приложение на Go, позволяющее: +- Регистрацию пользователей (Sign Up) +- Аутентификацию через JWT (Login, Logout) +- CRUD по тренировкам (Workouts), включая + - Планирование (Scheduled date/time) + - Упражнения внутри тренировки (sets, reps, weight) +- Генерацию отчётов о прошлых тренировках (по диапазону дат) +- Предзаполнение списка упражнений (Seeder) +- Защиту эндпоинтов авторизационным middleware + +## Структура + +- **cmd/main.go**: Точка входа (запуск сервера) +- **controllers/**: Обработчики (Handlers) для Auth, Workouts, Exercises +- **database/**: Подключение к SQLite и миграции, а также сидер +- **models/**: Определение структур (User, Exercise, Workout) +- **repositories/**: Прямая работа с БД (CRUD) +- **routes/**: Маршруты (Endpoints) +- **tests/**: Юнит-тесты (пример) +- **docker/Dockerfile**: Сборка Docker-образа +- **Makefile**: Упрощённые команды для сборки и запуска + +## Запуск локально + + +```bash +go mod tidy + +go run ./cmd/main.go +``` +или через Make +```bash +make run +make docker-build +make docker-run +``` + +## Примеры запросов + +``` +curl -X POST -H "Content-Type: application/json" \ + -d '{"email":"test@example.com","password":"secret"}' \ + http://localhost:8080/signup + +curl -X POST -H "Content-Type: application/json" \ + -d '{"email":"test@example.com","password":"secret"}' \ + http://localhost:8080/login + +curl -X POST -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "title": "Leg Day", + "scheduled": "2025-02-10T10:00:00Z", + "comment": "Focus on squats", + "exercises": [ + {"exerciseId": 2, "sets": 4, "reps": 12, "weight": 60}, + {"exerciseId": 1, "sets": 3, "reps": 10, "weight": 20} + ] + }' \ + http://localhost:8080/workouts + +curl -H "Authorization: Bearer " \ + http://localhost:8080/workouts?pending=true + +curl -H "Authorization: Bearer " \ + "http://localhost:8080/workouts/report?from=2025-02-01&to=2025-02-28" + + +curl -X POST -H "Content-Type: application/json" \ + -d '{ + "name": "Bench Press", + "description": "Chest exercise with barbell", + "category": "strength" + }' \ + http://localhost:8080/exercises + +curl http://localhost:8080/exercises + +curl http://localhost:8080/exercises/1 + + +curl -X PUT -H "Content-Type: application/json" \ + -d '{ + "name": "Barbell Bench Press", + "description": "Bench press with barbell", + "category": "strength" + }' \ + http://localhost:8080/exercises/1 + +curl -X DELETE http://localhost:8080/exercises/1 + +``` diff --git a/exercise9/cmd/main.go b/exercise9/cmd/main.go new file mode 100644 index 00000000..ca2ebb2a --- /dev/null +++ b/exercise9/cmd/main.go @@ -0,0 +1,20 @@ +package main + +import ( + "alinurmyrzakhanov/database" + "alinurmyrzakhanov/routes" + + "github.com/gin-gonic/gin" +) + +func main() { + database.InitDB() + + database.SeedExercises() + + r := gin.Default() + + routes.SetupRoutes(r) + + r.Run(":8080") +} diff --git a/exercise9/controllers/auth_controller.go b/exercise9/controllers/auth_controller.go new file mode 100644 index 00000000..9676f997 --- /dev/null +++ b/exercise9/controllers/auth_controller.go @@ -0,0 +1,159 @@ +package controllers + +import ( + "fmt" + "net/http" + "time" + + "alinurmyrzakhanov/models" + "alinurmyrzakhanov/repositories" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v4" + "golang.org/x/crypto/bcrypt" +) + +var jwtSecret = []byte("MY_SUPER_SECRET") + +func HashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(bytes), err +} + +func CheckPasswordHash(password, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} + +func generateJWT(userID uint) (string, error) { + claims := jwt.MapClaims{ + "user_id": userID, + "exp": time.Now().Add(time.Hour * 24).Unix(), + "iat": time.Now().Unix(), + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(jwtSecret) +} + +func AuthMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Токен не найден"}) + c.Abort() + return + } + + tokenStr := "" + _, err := fmt.Sscanf(authHeader, "Bearer %s", &tokenStr) + if err != nil || tokenStr == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Некорректный заголовок"}) + c.Abort() + return + } + + token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("неверный метод подписи") + } + return jwtSecret, nil + }) + if err != nil || !token.Valid { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Недействительный токен"}) + c.Abort() + return + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Недействительный payload"}) + c.Abort() + return + } + + userIDFloat, ok := claims["user_id"].(float64) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "user_id не найден в токене"}) + c.Abort() + return + } + c.Set("user_id", uint(userIDFloat)) + + c.Next() + } +} + +func SignUp(c *gin.Context) { + var input struct { + Email string `json:"email" binding:"required"` + Password string `json:"password" binding:"required"` + } + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + hashed, err := HashPassword(input.Password) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Не удалось захешировать пароль"}) + return + } + user := models.User{ + Email: input.Email, + PasswordHash: hashed, + } + if err := repositories.CreateUser(&user); err != nil { + c.JSON(http.StatusConflict, gin.H{"error": "Пользователь уже существует или другая ошибка"}) + return + } + c.JSON(http.StatusCreated, gin.H{ + "message": "Пользователь успешно создан", + "user": user.Email, + }) +} + +func Login(c *gin.Context) { + var input struct { + Email string `json:"email" binding:"required"` + Password string `json:"password" binding:"required"` + } + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + user, err := repositories.GetUserByEmail(input.Email) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Неверный логин или пароль"}) + return + } + + if !CheckPasswordHash(input.Password, user.PasswordHash) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Неверный логин или пароль"}) + return + } + + token, err := generateJWT(user.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Ошибка при генерации токена"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "token": token, + }) +} + +func Logout(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "message": "Вы успешно вышли из системы (токен считать недействительным на клиенте).", + }) +} + +func GetUserID(c *gin.Context) uint { + val, exists := c.Get("user_id") + if !exists { + return 0 + } + userID, _ := val.(uint) + return userID +} diff --git a/exercise9/controllers/exercise_controller.go b/exercise9/controllers/exercise_controller.go new file mode 100644 index 00000000..a75d7562 --- /dev/null +++ b/exercise9/controllers/exercise_controller.go @@ -0,0 +1,118 @@ +package controllers + +import ( + "net/http" + "strconv" + "time" + + "alinurmyrzakhanov/models" + "alinurmyrzakhanov/repositories" + + "github.com/gin-gonic/gin" +) + +func CreateExercise(c *gin.Context) { + var input struct { + Name string `json:"name" binding:"required"` + Description string `json:"description"` + Category string `json:"category" binding:"required"` + } + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ex := models.Exercise{ + Name: input.Name, + Description: input.Description, + Category: input.Category, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + if err := repositories.CreateExercise(&ex); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Не удалось создать упражнение"}) + return + } + + c.JSON(http.StatusCreated, ex) +} + +func GetExercises(c *gin.Context) { + exercises, err := repositories.GetAllExercises() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Ошибка при получении упражнений"}) + return + } + c.JSON(http.StatusOK, exercises) +} + +func GetExerciseByID(c *gin.Context) { + idParam := c.Param("id") + id, err := strconv.Atoi(idParam) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Некорректный ID"}) + return + } + + ex, err := repositories.GetExerciseByID(uint(id)) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Упражнение не найдено"}) + return + } + + c.JSON(http.StatusOK, ex) +} + +func UpdateExercise(c *gin.Context) { + idParam := c.Param("id") + id, err := strconv.Atoi(idParam) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Некорректный ID"}) + return + } + + existing, err := repositories.GetExerciseByID(uint(id)) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Упражнение не найдено"}) + return + } + + var input struct { + Name string `json:"name" binding:"required"` + Description string `json:"description"` + Category string `json:"category" binding:"required"` + } + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + existing.Name = input.Name + existing.Description = input.Description + existing.Category = input.Category + existing.UpdatedAt = time.Now() + + if err := repositories.UpdateExercise(existing); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Ошибка при обновлении упражнения"}) + return + } + + c.JSON(http.StatusOK, existing) +} + +func DeleteExercise(c *gin.Context) { + idParam := c.Param("id") + id, err := strconv.Atoi(idParam) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Некорректный ID"}) + return + } + + if err := repositories.DeleteExercise(uint(id)); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Ошибка при удалении упражнения"}) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/exercise9/controllers/workout_controller.go b/exercise9/controllers/workout_controller.go new file mode 100644 index 00000000..dba9d9ad --- /dev/null +++ b/exercise9/controllers/workout_controller.go @@ -0,0 +1,202 @@ +package controllers + +import ( + "net/http" + "strconv" + "time" + + "alinurmyrzakhanov/models" + "alinurmyrzakhanov/repositories" + + "github.com/gin-gonic/gin" +) + +func CreateWorkout(c *gin.Context) { + userID := GetUserID(c) + if userID == 0 { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Нет user_id"}) + return + } + + var input struct { + Title string `json:"title" binding:"required"` + Scheduled string `json:"scheduled"` + Comment string `json:"comment"` + WorkoutExercises []struct { + ExerciseID uint `json:"exerciseId" binding:"required"` + Sets int `json:"sets" binding:"required"` + Reps int `json:"reps" binding:"required"` + Weight int `json:"weight"` + Comment string `json:"comment"` + } `json:"exercises"` + } + + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var scheduled time.Time + if input.Scheduled != "" { + t, err := time.Parse(time.RFC3339, input.Scheduled) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Некорректный формат времени. Используйте RFC3339."}) + return + } + scheduled = t + } + + workout := models.Workout{ + UserID: userID, + Title: input.Title, + Scheduled: scheduled, + Comment: input.Comment, + IsDone: false, + } + for _, wex := range input.WorkoutExercises { + workout.WorkoutExercises = append(workout.WorkoutExercises, models.WorkoutExercise{ + ExerciseID: wex.ExerciseID, + Sets: wex.Sets, + Reps: wex.Reps, + Weight: wex.Weight, + Comment: wex.Comment, + }) + } + + if err := repositories.CreateWorkout(&workout); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Ошибка при создании тренировки"}) + return + } + c.JSON(http.StatusCreated, workout) +} + +func UpdateWorkout(c *gin.Context) { + userID := GetUserID(c) + idParam := c.Param("id") + workoutID, err := strconv.Atoi(idParam) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Некорректный ID"}) + return + } + + existing, err := repositories.GetWorkoutByID(uint(workoutID), userID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Тренировка не найдена"}) + return + } + + var input struct { + Title string `json:"title" binding:"required"` + Scheduled string `json:"scheduled"` + Comment string `json:"comment"` + IsDone bool `json:"isDone"` + WorkoutExercises []struct { + ExerciseID uint `json:"exerciseId" binding:"required"` + Sets int `json:"sets" binding:"required"` + Reps int `json:"reps" binding:"required"` + Weight int `json:"weight"` + Comment string `json:"comment"` + } `json:"exercises"` + } + + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + existing.Title = input.Title + existing.Comment = input.Comment + existing.IsDone = input.IsDone + + if input.Scheduled != "" { + t, err := time.Parse(time.RFC3339, input.Scheduled) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Некорректный формат времени"}) + return + } + existing.Scheduled = t + } + var newWex []models.WorkoutExercise + for _, wex := range input.WorkoutExercises { + newWex = append(newWex, models.WorkoutExercise{ + ExerciseID: wex.ExerciseID, + Sets: wex.Sets, + Reps: wex.Reps, + Weight: wex.Weight, + Comment: wex.Comment, + }) + } + existing.WorkoutExercises = newWex + + if err := repositories.UpdateWorkout(existing); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Ошибка при обновлении"}) + return + } + + c.JSON(http.StatusOK, existing) +} + +func DeleteWorkout(c *gin.Context) { + userID := GetUserID(c) + idParam := c.Param("id") + workoutID, err := strconv.Atoi(idParam) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Некорректный ID"}) + return + } + + if err := repositories.DeleteWorkout(uint(workoutID), userID); err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Тренировка не найдена или ошибка при удалении"}) + return + } + c.Status(http.StatusNoContent) +} + +func ListWorkouts(c *gin.Context) { + userID := GetUserID(c) + onlyPending := c.Query("pending") == "true" + + workouts, err := repositories.ListWorkouts(userID, onlyPending) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Ошибка при получении списка"}) + return + } + c.JSON(http.StatusOK, workouts) +} + +func GetWorkoutReport(c *gin.Context) { + userID := GetUserID(c) + fromStr := c.Query("from") + toStr := c.Query("to") + + if fromStr == "" || toStr == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Параметры ?from=YYYY-MM-DD&to=YYYY-MM-DD обязательны"}) + return + } + + from, errFrom := time.Parse("2006-01-02", fromStr) + to, errTo := time.Parse("2006-01-02", toStr) + if errFrom != nil || errTo != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Неверный формат дат (используйте YYYY-MM-DD)"}) + return + } + if from.After(to) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Дата 'from' не может быть позже 'to'"}) + return + } + + workouts, err := repositories.GetWorkoutsInRange(userID, from, to) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Ошибка при формировании отчёта"}) + return + } + + totalWorkouts := len(workouts) + + c.JSON(http.StatusOK, gin.H{ + "from": fromStr, + "to": toStr, + "totalRecords": totalWorkouts, + "workouts": workouts, + }) +} diff --git a/exercise9/database/database.go b/exercise9/database/database.go new file mode 100644 index 00000000..807e0c44 --- /dev/null +++ b/exercise9/database/database.go @@ -0,0 +1,45 @@ +package database + +import ( + "fmt" + "log" + "os" + "time" + + "alinurmyrzakhanov/models" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +var DB *gorm.DB + +func InitDB() { + dbPath := "myworkout.db" + if envPath := os.Getenv("DB_PATH"); envPath != "" { + dbPath = envPath + } + + dsn := fmt.Sprintf("%s", dbPath) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + if err != nil { + log.Fatal("Не удалось подключиться к БД:", err) + } + + err = db.AutoMigrate( + &models.User{}, + &models.Exercise{}, + &models.Workout{}, + &models.WorkoutExercise{}, + ) + if err != nil { + log.Fatal("Ошибка миграции:", err) + } + + sqlDB, _ := db.DB() + sqlDB.SetMaxIdleConns(10) + sqlDB.SetMaxOpenConns(100) + sqlDB.SetConnMaxLifetime(time.Hour) + + DB = db +} diff --git a/exercise9/database/seed.go b/exercise9/database/seed.go new file mode 100644 index 00000000..b465512d --- /dev/null +++ b/exercise9/database/seed.go @@ -0,0 +1,30 @@ +package database + +import ( + "log" + "time" + + "alinurmyrzakhanov/models" +) + +func SeedExercises() { + var count int64 + DB.Model(&models.Exercise{}).Count(&count) + if count > 0 { + log.Println("Exercises уже существуют, сидер пропущен.") + return + } + + exercises := []models.Exercise{ + {Name: "Push-up", Description: "Classic push-up for chest", Category: "strength", CreatedAt: time.Now(), UpdatedAt: time.Now()}, + {Name: "Squat", Description: "Bodyweight squat for legs", Category: "strength", CreatedAt: time.Now(), UpdatedAt: time.Now()}, + {Name: "Running", Description: "Running cardio exercise", Category: "cardio", CreatedAt: time.Now(), UpdatedAt: time.Now()}, + {Name: "Plank", Description: "Core strength exercise", Category: "core", CreatedAt: time.Now(), UpdatedAt: time.Now()}, + } + + if err := DB.Create(&exercises).Error; err != nil { + log.Println("Ошибка при создании упражнений:", err) + } else { + log.Println("Exercises успешно засеяны в БД.") + } +} diff --git a/exercise9/go.mod b/exercise9/go.mod new file mode 100644 index 00000000..126d0334 --- /dev/null +++ b/exercise9/go.mod @@ -0,0 +1,42 @@ +module alinurmyrzakhanov + +go 1.23.3 + +require ( + github.com/bytedance/sonic v1.12.8 // indirect + github.com/bytedance/sonic/loader v0.2.3 // indirect + github.com/cloudwego/base64x v0.1.5 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/gin-contrib/sse v1.0.0 // indirect + github.com/gin-gonic/gin v1.10.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.24.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/golang-jwt/jwt/v4 v4.5.1 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.9 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.24 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/testify v1.10.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + golang.org/x/arch v0.14.0 // indirect + golang.org/x/crypto v0.32.0 // indirect + golang.org/x/net v0.34.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.22.0 // indirect + google.golang.org/protobuf v1.36.5 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + gorm.io/driver/sqlite v1.5.7 // indirect + gorm.io/gorm v1.25.12 // indirect +) diff --git a/exercise9/go.sum b/exercise9/go.sum new file mode 100644 index 00000000..4e15a3bb --- /dev/null +++ b/exercise9/go.sum @@ -0,0 +1,93 @@ +github.com/bytedance/sonic v1.12.8 h1:4xYRVRlXIgvSZ4e8iVTlMF5szgpXd4AfvuWgA8I8lgs= +github.com/bytedance/sonic v1.12.8/go.mod h1:uVvFidNmlt9+wa31S1urfwwthTWteBgG0hWuoKAXTx8= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/bytedance/sonic/loader v0.2.3 h1:yctD0Q3v2NOGfSWPLPvG2ggA2kV6TS6s4wioyEqssH0= +github.com/bytedance/sonic/loader v0.2.3/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= +github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E= +github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.24.0 h1:KHQckvo8G6hlWnrPX4NJJ+aBfWNAE/HH+qdL2cBpCmg= +github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo= +github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= +github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= +github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +golang.org/x/arch v0.14.0 h1:z9JUEZWr8x4rR0OU6c4/4t6E6jOZ8/QBS2bBYBm4tx4= +golang.org/x/arch v0.14.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I= +gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4= +gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= +gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/exercise9/models/exercise.go b/exercise9/models/exercise.go new file mode 100644 index 00000000..8ab90182 --- /dev/null +++ b/exercise9/models/exercise.go @@ -0,0 +1,14 @@ +package models + +import ( + "time" +) + +type Exercise struct { + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"not null" json:"name"` + Description string `json:"description"` + Category string `json:"category"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} diff --git a/exercise9/models/user.go b/exercise9/models/user.go new file mode 100644 index 00000000..0249b559 --- /dev/null +++ b/exercise9/models/user.go @@ -0,0 +1,13 @@ +package models + +import ( + "time" +) + +type User struct { + ID uint `gorm:"primaryKey" json:"id"` + Email string `gorm:"unique; not null" json:"email"` + PasswordHash string `json:"-"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} diff --git a/exercise9/models/workout.go b/exercise9/models/workout.go new file mode 100644 index 00000000..b7e7ed6a --- /dev/null +++ b/exercise9/models/workout.go @@ -0,0 +1,32 @@ +package models + +import ( + "time" +) + +type Workout struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"index" json:"userId"` + Title string `gorm:"not null" json:"title"` + Scheduled time.Time `json:"scheduled"` + Comment string `json:"comment"` + IsDone bool `json:"isDone"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + WorkoutExercises []WorkoutExercise `json:"exercises"` +} + +type WorkoutExercise struct { + ID uint `gorm:"primaryKey" json:"id"` + WorkoutID uint `gorm:"index" json:"workoutId"` + ExerciseID uint `gorm:"index" json:"exerciseId"` + Sets int `json:"sets"` + Reps int `json:"reps"` + Weight int `json:"weight"` + Comment string `json:"comment"` + + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + + Exercise Exercise `gorm:"foreignKey:ID;references:ExerciseID" json:"exercise"` +} diff --git a/exercise9/repositories/exercise_repository.go b/exercise9/repositories/exercise_repository.go new file mode 100644 index 00000000..770c0fc2 --- /dev/null +++ b/exercise9/repositories/exercise_repository.go @@ -0,0 +1,36 @@ +package repositories + +import ( + "alinurmyrzakhanov/database" + "alinurmyrzakhanov/models" +) + +func CreateExercise(ex *models.Exercise) error { + return database.DB.Create(ex).Error +} + +func GetExerciseByID(id uint) (*models.Exercise, error) { + var ex models.Exercise + err := database.DB.First(&ex, id).Error + if err != nil { + return nil, err + } + return &ex, nil +} + +func GetAllExercises() ([]models.Exercise, error) { + var exercises []models.Exercise + err := database.DB.Find(&exercises).Error + if err != nil { + return nil, err + } + return exercises, nil +} + +func UpdateExercise(ex *models.Exercise) error { + return database.DB.Save(ex).Error +} + +func DeleteExercise(id uint) error { + return database.DB.Delete(&models.Exercise{}, id).Error +} diff --git a/exercise9/repositories/user_repository.go b/exercise9/repositories/user_repository.go new file mode 100644 index 00000000..091f8278 --- /dev/null +++ b/exercise9/repositories/user_repository.go @@ -0,0 +1,34 @@ +package repositories + +import ( + "alinurmyrzakhanov/database" + "alinurmyrzakhanov/models" + + "gorm.io/gorm" +) + +func CreateUser(user *models.User) error { + return database.DB.Create(user).Error +} + +func GetUserByEmail(email string) (*models.User, error) { + var user models.User + err := database.DB.Where("email = ?", email).First(&user).Error + if err != nil { + return nil, err + } + return &user, nil +} + +func GetUserByID(id uint) (*models.User, error) { + var user models.User + err := database.DB.First(&user, id).Error + if err != nil { + return nil, err + } + return &user, nil +} + +func IsRecordNotFound(err error) bool { + return err == gorm.ErrRecordNotFound +} diff --git a/exercise9/repositories/workout_repository.go b/exercise9/repositories/workout_repository.go new file mode 100644 index 00000000..0442fd37 --- /dev/null +++ b/exercise9/repositories/workout_repository.go @@ -0,0 +1,95 @@ +package repositories + +import ( + "alinurmyrzakhanov/database" + "alinurmyrzakhanov/models" + "time" + + "gorm.io/gorm" +) + +func CreateWorkout(workout *models.Workout) error { + return database.DB.Transaction(func(tx *gorm.DB) error { + // Сначала создаём сам Workout + if err := tx.Create(workout).Error; err != nil { + return err + } + for i := range workout.WorkoutExercises { + workout.WorkoutExercises[i].WorkoutID = workout.ID + if err := tx.Create(&workout.WorkoutExercises[i]).Error; err != nil { + return err + } + } + return nil + }) +} + +func GetWorkoutByID(id uint, userID uint) (*models.Workout, error) { + var workout models.Workout + err := database.DB.Preload("WorkoutExercises.Exercise"). + Where("id = ? AND user_id = ?", id, userID). + First(&workout).Error + if err != nil { + return nil, err + } + return &workout, nil +} + +func UpdateWorkout(workout *models.Workout) error { + return database.DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Save(workout).Error; err != nil { + return err + } + if err := tx.Where("workout_id = ?", workout.ID).Delete(&models.WorkoutExercise{}).Error; err != nil { + return err + } + for i := range workout.WorkoutExercises { + workout.WorkoutExercises[i].WorkoutID = workout.ID + if err := tx.Create(&workout.WorkoutExercises[i]).Error; err != nil { + return err + } + } + return nil + }) +} + +func DeleteWorkout(id uint, userID uint) error { + return database.DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("id = ? AND user_id = ?", id, userID). + Delete(&models.Workout{}).Error; err != nil { + return err + } + if err := tx.Where("workout_id = ?", id).Delete(&models.WorkoutExercise{}).Error; err != nil { + return err + } + return nil + }) +} + +func ListWorkouts(userID uint, onlyPending bool) ([]models.Workout, error) { + var workouts []models.Workout + query := database.DB.Preload("WorkoutExercises.Exercise"). + Where("user_id = ?", userID) + + if onlyPending { + now := time.Now() + query = query.Where("scheduled >= ? OR is_done = false", now) + } + err := query.Order("scheduled ASC").Find(&workouts).Error + if err != nil { + return nil, err + } + return workouts, nil +} + +func GetWorkoutsInRange(userID uint, from, to time.Time) ([]models.Workout, error) { + var workouts []models.Workout + err := database.DB.Preload("WorkoutExercises.Exercise"). + Where("user_id = ? AND scheduled BETWEEN ? AND ?", userID, from, to). + Order("scheduled ASC"). + Find(&workouts).Error + if err != nil { + return nil, err + } + return workouts, nil +} diff --git a/exercise9/routes/routes.go b/exercise9/routes/routes.go new file mode 100644 index 00000000..8220b48a --- /dev/null +++ b/exercise9/routes/routes.go @@ -0,0 +1,32 @@ +package routes + +import ( + "alinurmyrzakhanov/controllers" + + "github.com/gin-gonic/gin" +) + +func SetupRoutes(r *gin.Engine) { + r.POST("/signup", controllers.SignUp) + r.POST("/login", controllers.Login) + r.POST("/logout", controllers.AuthMiddleware(), controllers.Logout) + + exerciseGroup := r.Group("/exercises") + { + exerciseGroup.POST("", controllers.CreateExercise) + exerciseGroup.GET("", controllers.GetExercises) + exerciseGroup.GET("/:id", controllers.GetExerciseByID) + exerciseGroup.PUT("/:id", controllers.UpdateExercise) + exerciseGroup.DELETE("/:id", controllers.DeleteExercise) + } + + authGroup := r.Group("/workouts") + authGroup.Use(controllers.AuthMiddleware()) + { + authGroup.POST("", controllers.CreateWorkout) + authGroup.GET("", controllers.ListWorkouts) + authGroup.GET("/report", controllers.GetWorkoutReport) + authGroup.PUT("/:id", controllers.UpdateWorkout) + authGroup.DELETE("/:id", controllers.DeleteWorkout) + } +} diff --git a/exercise9/tests/auth_test.go b/exercise9/tests/auth_test.go new file mode 100644 index 00000000..ef7da154 --- /dev/null +++ b/exercise9/tests/auth_test.go @@ -0,0 +1,219 @@ +package tests + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" + + "alinurmyrzakhanov/database" + "alinurmyrzakhanov/models" + "alinurmyrzakhanov/repositories" + "alinurmyrzakhanov/routes" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func setupTestDB(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("Не удалось открыть in-memory DB: %v", err) + } + + err = db.AutoMigrate( + &models.User{}, + &models.Exercise{}, + &models.Workout{}, + &models.WorkoutExercise{}, + ) + if err != nil { + t.Fatalf("Ошибка миграции: %v", err) + } + + database.DB = db +} + +func setupRouter() *gin.Engine { + r := gin.Default() + routes.SetupRoutes(r) + return r +} + +func createTestExercise(t *testing.T, name, category string) *models.Exercise { + ex := models.Exercise{ + Name: name, + Description: "test desc", + Category: category, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + err := repositories.CreateExercise(&ex) + if err != nil { + t.Fatalf("Не удалось создать упражнение в тесте: %v", err) + } + return &ex +} + +func TestCreateExercise(t *testing.T) { + setupTestDB(t) + router := setupRouter() + + body := `{ + "name": "Bench Press", + "description": "Chest exercise", + "category": "strength" + }` + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/exercises", bytes.NewBuffer([]byte(body))) + req.Header.Set("Content-Type", "application/json") + + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusCreated, w.Code, "Должен вернуться статус 201") + + var created models.Exercise + err := json.Unmarshal(w.Body.Bytes(), &created) + assert.Nil(t, err) + + assert.Equal(t, "Bench Press", created.Name) + assert.Equal(t, "strength", created.Category) + assert.NotZero(t, created.ID, "ID должен быть сгенерирован") +} + +func TestGetAllExercises(t *testing.T) { + setupTestDB(t) + router := setupRouter() + + createTestExercise(t, "Push-up", "strength") + createTestExercise(t, "Squat", "strength") + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/exercises", nil) + + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code, "Статус должен быть 200") + + var exercises []models.Exercise + err := json.Unmarshal(w.Body.Bytes(), &exercises) + assert.Nil(t, err) + + assert.GreaterOrEqual(t, len(exercises), 2, "Должно вернуться как минимум 2 упражнения") +} + +func TestGetExerciseByID(t *testing.T) { + setupTestDB(t) + router := setupRouter() + + testEx := createTestExercise(t, "Push-up", "strength") + + w := httptest.NewRecorder() + + url := "/exercises/" + strconv.Itoa(int(testEx.ID)) + req, _ := http.NewRequest("GET", url, nil) + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var fetched models.Exercise + err := json.Unmarshal(w.Body.Bytes(), &fetched) + assert.Nil(t, err) + + assert.Equal(t, testEx.ID, fetched.ID) + assert.Equal(t, "Push-up", fetched.Name) +} + +func TestUpdateExercise(t *testing.T) { + setupTestDB(t) + router := setupRouter() + + testEx := createTestExercise(t, "Bench Press", "strength") + + updateBody := `{ + "name": "Bench Press (updated)", + "description": "New desc", + "category": "upper body" + }` + w := httptest.NewRecorder() + req, _ := http.NewRequest("PUT", + "/exercises/"+(func() string { + return string(rune(testEx.ID + '0')) + })(), + bytes.NewBuffer([]byte(updateBody)), + ) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var updated models.Exercise + err := json.Unmarshal(w.Body.Bytes(), &updated) + assert.Nil(t, err) + + assert.Equal(t, testEx.ID, updated.ID) + assert.Equal(t, "Bench Press (updated)", updated.Name) + assert.Equal(t, "New desc", updated.Description) + assert.Equal(t, "upper body", updated.Category) +} + +func TestDeleteExercise(t *testing.T) { + setupTestDB(t) + router := setupRouter() + + testEx := createTestExercise(t, "Plank", "core") + + w := httptest.NewRecorder() + req, _ := http.NewRequest("DELETE", + "/exercises/"+(func() string { + return string(rune(testEx.ID + '0')) + })(), + nil, + ) + + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNoContent, w.Code, "Ожидаем 204 No Content") + + w2 := httptest.NewRecorder() + req2, _ := http.NewRequest("GET", + "/exercises/"+(func() string { + return string(rune(testEx.ID + '0')) + })(), + nil, + ) + router.ServeHTTP(w2, req2) + + assert.Equal(t, http.StatusNotFound, w2.Code, "После удаления GET должен вернуть 404") +} + +func TestGetNonExistentExercise(t *testing.T) { + setupTestDB(t) + router := setupRouter() + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/exercises/9999", nil) + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code, "Ожидаем 404 для несуществующего ID") +} + +func TestCreateExercise_ValidationError(t *testing.T) { + setupTestDB(t) + router := setupRouter() + body := `{ + "description": "Missing name", + "category": "strength" + }` + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/exercises", bytes.NewBuffer([]byte(body))) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code, "Ожидаем 400 Bad Request") +} diff --git a/go.mod b/go.mod index cdc0d2e5..4cba3d61 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/talgat-ruby/exercises-go -go 1.22.3 +go 1.23