From 4e23e12e3d6932861d9974c1e1cacc68b6a737ac Mon Sep 17 00:00:00 2001 From: turaroay Date: Tue, 30 Jul 2024 16:42:58 +0500 Subject: [PATCH 01/15] feat: ex 1 --- exercise1/problem1/main.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/exercise1/problem1/main.go b/exercise1/problem1/main.go index dfca465c..3e736d1e 100644 --- a/exercise1/problem1/main.go +++ b/exercise1/problem1/main.go @@ -1,3 +1,12 @@ package main -func addUp() {} +func addUp(num int) int { + if num == 0 || num == 1 { + return num + } + total := 0 + for i := num; i > 0; i-- { + total += i + } + return total +} From 85fed589e40c609c5dd97e4876388356cbf87502 Mon Sep 17 00:00:00 2001 From: turaroay Date: Tue, 30 Jul 2024 16:56:46 +0500 Subject: [PATCH 02/15] feat: ex 2 --- exercise1/problem2/main.go | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/exercise1/problem2/main.go b/exercise1/problem2/main.go index 2ca540b8..bab3ab89 100644 --- a/exercise1/problem2/main.go +++ b/exercise1/problem2/main.go @@ -1,3 +1,21 @@ package main -func binary() {} +import "fmt" + +func main() { + //{666, "1010011010"}, + fmt.Println(binary(0)) +} + +func binary(num int) string { + if num == 0 { + return "0" + } + bin := "" + for num > 0 { + remainder := num % 2 + bin = string(rune(remainder)+'0') + bin + num = num / 2 + } + return bin +} From ad7d6ac1e99957e00c599161736b2c8bb1eabcb6 Mon Sep 17 00:00:00 2001 From: turaroay Date: Tue, 30 Jul 2024 17:06:18 +0500 Subject: [PATCH 03/15] feat: ex 3 --- exercise1/problem3/main.go | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/exercise1/problem3/main.go b/exercise1/problem3/main.go index d346641a..ffc7c212 100644 --- a/exercise1/problem3/main.go +++ b/exercise1/problem3/main.go @@ -1,3 +1,19 @@ package main -func numberSquares() {} +import "fmt" + +func main() { + fmt.Println(numberSquares(2)) +} + +func numberSquares(n int) int { + // 1^2 + 2^2 + 3^2 + … + N^2 + if n == 0 { + return 0 + } + var total = 0 + for i := 1; i <= n; i++ { + total += i * i + } + return total +} From 4e9413d7b2e0e636ba9f9ba00ac4637beac52d56 Mon Sep 17 00:00:00 2001 From: turaroay Date: Tue, 30 Jul 2024 17:17:03 +0500 Subject: [PATCH 04/15] feat: ex 4 --- exercise1/problem4/main.go | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/exercise1/problem4/main.go b/exercise1/problem4/main.go index 74af9044..6b9b98a7 100644 --- a/exercise1/problem4/main.go +++ b/exercise1/problem4/main.go @@ -1,3 +1,17 @@ package main -func detectWord() {} +import "fmt" + +func main() { + fmt.Println(detectWord("UcUNFYGaFYFYGtNUH")) +} + +func detectWord(s string) string { + letter := "" + for _, c := range s { + if c >= 'a' && c <= 'z' { + letter += string(c) + } + } + return letter +} From a5dd21681cdaea0610fbbfd8fa3705fd9689df2f Mon Sep 17 00:00:00 2001 From: turaroay Date: Tue, 30 Jul 2024 17:24:55 +0500 Subject: [PATCH 05/15] feat: ex 5 --- exercise1/problem5/main.go | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/exercise1/problem5/main.go b/exercise1/problem5/main.go index c5a804c9..1a307f01 100644 --- a/exercise1/problem5/main.go +++ b/exercise1/problem5/main.go @@ -1,3 +1,30 @@ package main -func potatoes() {} +import "fmt" + +func main() { + fmt.Println(potatoes("potatoapple")) +} + +func potatoes(s string) int { + target := "potato" + count := 0 + targetLen := len(target) + + for i := 0; i <= len(s)-targetLen; { + match := true + for j := 0; j < targetLen; j++ { + if s[i+j] != target[j] { + match = false + break + } + } + if match { + count++ + i += targetLen + } else { + i++ + } + } + return count +} From aba3325bd48bb275411a19fe9fee5bad17591a0f Mon Sep 17 00:00:00 2001 From: turaroay Date: Tue, 30 Jul 2024 18:01:56 +0500 Subject: [PATCH 06/15] feat: ex 6 --- exercise1/problem6/main.go | 55 +++++++++++++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/exercise1/problem6/main.go b/exercise1/problem6/main.go index 06043890..b27627b8 100644 --- a/exercise1/problem6/main.go +++ b/exercise1/problem6/main.go @@ -1,3 +1,56 @@ package main -func emojify() {} +import "fmt" + +func main() { + fmt.Println(emojify("smile, grin, sad, mad")) +} + +func emojify(s string) string { + words := splitIntoWords(s) + fmt.Println(words) + text := "" + + for _, word := range words { + text += getEmoji(word) + } + return text + +} +func getEmoji(word string) string { + switch word { + case "smile": + return "πŸ™‚" + case "grin": + return "πŸ˜€" + case "sad": + return "πŸ˜₯" + case "mad": + return "😠" + default: + return word + } +} +func isSpace(char byte) bool { + return char == ' ' || char == '\t' || char == '\n' || char == '\r' || char == '(' || char == ')' || char == ',' +} + +func splitIntoWords(text string) []string { + var words []string + tempText := "" + + for i := 0; i < len(text); i++ { + if i == len(text)-1 && !isSpace(text[i]) { + tempText += string(text[i]) + words = append(words, tempText) + } else if !isSpace(text[i]) { + tempText += string(text[i]) + } else { + words = append(words, tempText) + words = append(words, string(text[i])) + tempText = "" + } + } + + return words +} From 6750a5ffbb2325a7eac93f1e619b8dbba189b34e Mon Sep 17 00:00:00 2001 From: turaroay Date: Tue, 30 Jul 2024 18:06:48 +0500 Subject: [PATCH 07/15] feat: ex 7 --- exercise1/problem7/main.go | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/exercise1/problem7/main.go b/exercise1/problem7/main.go index 57c99b5c..569f9712 100644 --- a/exercise1/problem7/main.go +++ b/exercise1/problem7/main.go @@ -1,3 +1,25 @@ package main -func highestDigit() {} +import "fmt" + +func main() { + fmt.Println(highestDigit(379)) +} + +func highestDigit(n int) int { + var digits []int + var highest int + for n > 0 { + digit := n % 10 + n = n / 10 + digits = append(digits, digit) + } + fmt.Println(digits) + + for _, num := range digits { + if num > highest { + highest = num + } + } + return highest +} From 85446c342a74618fb7004df9e1c5c5d718689da2 Mon Sep 17 00:00:00 2001 From: turaroay Date: Tue, 30 Jul 2024 18:10:52 +0500 Subject: [PATCH 08/15] feat: ex 8 --- exercise1/problem8/main.go | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/exercise1/problem8/main.go b/exercise1/problem8/main.go index 97fa0dae..95332729 100644 --- a/exercise1/problem8/main.go +++ b/exercise1/problem8/main.go @@ -1,3 +1,14 @@ package main -func countVowels() {} +func countVowels(s string) int { + var vowels = []rune{'a', 'e', 'i', 'o', 'u'} + count := 0 + for _, c := range s { + for _, vowel := range vowels { + if vowel == c { + count++ + } + } + } + return count +} From 453435751e962a07f6297d7c5661e8edfbaae5c4 Mon Sep 17 00:00:00 2001 From: turaroay Date: Wed, 31 Jul 2024 17:49:57 +0500 Subject: [PATCH 09/15] feat: ex 10 --- exercise1/problem10/main.go | 47 ++++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/exercise1/problem10/main.go b/exercise1/problem10/main.go index 04ec3430..9c1e14c7 100644 --- a/exercise1/problem10/main.go +++ b/exercise1/problem10/main.go @@ -1,3 +1,48 @@ package main -func sum() {} +import "errors" + +func main() { + // fmt.Println("Hello, δΈ–η•Œ") + // sum("10", "20") // "30", nil + sum("10", "20") +} + +func sum(str1 string, str2 string) (string, error) { + if !isInt(str1) || !isInt(str2) { + return "", errors.New("string: a cannot be converted") + } + num1 := atoi(str1) + num2 := atoi(str2) + result := itoa(num1 + num2) + return result, nil +} + +func atoi(s string) int { + base := 1 + num := 0 + for i := len(s) - 1; i >= 0; i-- { + num += int(s[i]-'0') * base + base *= 10 + } + return num +} + +func itoa(n int) string { + num := "" + for n > 0 { + num = string(rune(n%10+'0')) + num + n /= 10 + } + + return num +} + +func isInt(s string) bool { + for _, c := range s { + if c < '0' || c > '9' { + return false + } + } + return true +} From 9835d064ee84c828f8f7178dafad87a675a02064 Mon Sep 17 00:00:00 2001 From: turaroay Date: Wed, 31 Jul 2024 17:50:49 +0500 Subject: [PATCH 10/15] feat: ex 9 --- exercise1/problem9/main.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/exercise1/problem9/main.go b/exercise1/problem9/main.go index e8c84a54..0bf4ef82 100644 --- a/exercise1/problem9/main.go +++ b/exercise1/problem9/main.go @@ -1,7 +1,13 @@ package main -func bitwiseAND() {} +func bitwiseAND(n1 int, n2 int) int { + return n1 & n2 +} -func bitwiseOR() {} +func bitwiseOR(n1 int, n2 int) int { + return n1 | n2 +} -func bitwiseXOR() {} +func bitwiseXOR(n1 int, n2 int) int { + return n1 ^ n2 +} From cc5efc358f1d2e9062ac4425e0e7d966aca00f1f Mon Sep 17 00:00:00 2001 From: turaroay Date: Wed, 25 Sep 2024 18:35:18 +0500 Subject: [PATCH 11/15] feat: ex 2 --- exercise2/README.md | 16 ++++++ exercise2/problem1/README.md | 23 ++++++++ exercise2/problem1/problem1.go | 10 ++++ exercise2/problem1/problem1_test.go | 28 ++++++++++ exercise2/problem10/README.md | 15 +++++ exercise2/problem10/problem10.go | 13 +++++ exercise2/problem10/problem10_test.go | 22 ++++++++ exercise2/problem11/README.md | 12 ++++ exercise2/problem11/problem11.go | 14 +++++ exercise2/problem11/problem11_test.go | 40 ++++++++++++++ exercise2/problem12/README.md | 12 ++++ exercise2/problem12/problem12.go | 27 +++++++++ exercise2/problem12/problem12_test.go | 54 ++++++++++++++++++ exercise2/problem2/README.md | 12 ++++ exercise2/problem2/problem2.go | 36 ++++++++++++ exercise2/problem2/problem2_test.go | 29 ++++++++++ exercise2/problem3/README.md | 33 +++++++++++ exercise2/problem3/problem3.go | 53 ++++++++++++++++++ exercise2/problem3/problem3_test.go | 72 ++++++++++++++++++++++++ exercise2/problem4/README.md | 14 +++++ exercise2/problem4/problem4.go | 14 +++++ exercise2/problem4/problem4_test.go | 33 +++++++++++ exercise2/problem5/README.md | 13 +++++ exercise2/problem5/problem5.go | 25 +++++++++ exercise2/problem5/problem5_test.go | 47 ++++++++++++++++ exercise2/problem6/README.md | 16 ++++++ exercise2/problem6/problem6.go | 12 ++++ exercise2/problem6/problem6_test.go | 80 +++++++++++++++++++++++++++ exercise2/problem7/README.md | 9 +++ exercise2/problem7/problem7.go | 7 +++ exercise2/problem7/problem7_test.go | 14 +++++ exercise2/problem8/README.md | 10 ++++ exercise2/problem8/problem8.go | 14 +++++ exercise2/problem8/problem8_test.go | 35 ++++++++++++ exercise2/problem9/README.md | 13 +++++ exercise2/problem9/problem9.go | 10 ++++ exercise2/problem9/problem9_test.go | 53 ++++++++++++++++++ 37 files changed, 940 insertions(+) create mode 100644 exercise2/README.md create mode 100644 exercise2/problem1/README.md create mode 100644 exercise2/problem1/problem1.go create mode 100644 exercise2/problem1/problem1_test.go create mode 100644 exercise2/problem10/README.md create mode 100644 exercise2/problem10/problem10.go create mode 100644 exercise2/problem10/problem10_test.go create mode 100644 exercise2/problem11/README.md create mode 100644 exercise2/problem11/problem11.go create mode 100644 exercise2/problem11/problem11_test.go create mode 100644 exercise2/problem12/README.md create mode 100644 exercise2/problem12/problem12.go create mode 100644 exercise2/problem12/problem12_test.go create mode 100644 exercise2/problem2/README.md create mode 100644 exercise2/problem2/problem2.go create mode 100644 exercise2/problem2/problem2_test.go create mode 100644 exercise2/problem3/README.md create mode 100644 exercise2/problem3/problem3.go create mode 100644 exercise2/problem3/problem3_test.go create mode 100644 exercise2/problem4/README.md create mode 100644 exercise2/problem4/problem4.go create mode 100644 exercise2/problem4/problem4_test.go create mode 100644 exercise2/problem5/README.md create mode 100644 exercise2/problem5/problem5.go create mode 100644 exercise2/problem5/problem5_test.go create mode 100644 exercise2/problem6/README.md create mode 100644 exercise2/problem6/problem6.go create mode 100644 exercise2/problem6/problem6_test.go create mode 100644 exercise2/problem7/README.md create mode 100644 exercise2/problem7/problem7.go create mode 100644 exercise2/problem7/problem7_test.go create mode 100644 exercise2/problem8/README.md create mode 100644 exercise2/problem8/problem8.go create mode 100644 exercise2/problem8/problem8_test.go create mode 100644 exercise2/problem9/README.md create mode 100644 exercise2/problem9/problem9.go create mode 100644 exercise2/problem9/problem9_test.go diff --git a/exercise2/README.md b/exercise2/README.md new file mode 100644 index 00000000..bf26b744 --- /dev/null +++ b/exercise2/README.md @@ -0,0 +1,16 @@ +# Exercise 2 + +Please provide solution for the following problems * are 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) * +9. [problem9](./problem9/README.md) * +10. [problem10](./problem10/README.md) * +11. [problem11](./problem11/README.md) * +12. [problem12](./problem12/README.md) * diff --git a/exercise2/problem1/README.md b/exercise2/problem1/README.md new file mode 100644 index 00000000..ee9a3f8c --- /dev/null +++ b/exercise2/problem1/README.md @@ -0,0 +1,23 @@ +# Problem 1 + +Given a total due and an array representing the amount of change in your pocket, determine whether you are able +to pay for the item. Change will always be represented in the following order: quarters, dimes, nickels, pennies. + +```go +changeEnough([2, 100, 0, 0], 14.11) // false + +changeEnough([0, 0, 20, 5], 0.75) // true + +changeEnough([30, 40, 20, 5], 12.55) // true + +changeEnough([10, 0, 0, 50], 3.85) // false + +changeEnough([1, 0, 5, 219], 19.99) // false +``` + +Notes + +- **quarter**: 25 cents / $0.25 +- **dime**: 10 cents / $0.10 +- **nickel**: 5 cents / $0.05 +- **penny**: 1 cent / $0.01 diff --git a/exercise2/problem1/problem1.go b/exercise2/problem1/problem1.go new file mode 100644 index 00000000..23c53890 --- /dev/null +++ b/exercise2/problem1/problem1.go @@ -0,0 +1,10 @@ +package problem1 + +func isChangeEnough(arr [4]int, amount float32) bool { + quarters := 25 * arr[0] + dimes := 10 * arr[1] + nickels := 5 * arr[2] + pennies := 1 * arr[3] + sum := (float32)(quarters + dimes + nickels + pennies) + return sum-amount*100 > -1 +} diff --git a/exercise2/problem1/problem1_test.go b/exercise2/problem1/problem1_test.go new file mode 100644 index 00000000..d273a374 --- /dev/null +++ b/exercise2/problem1/problem1_test.go @@ -0,0 +1,28 @@ +package problem1 + +import ( + "testing" +) + +func TestIsChangeEnough(t *testing.T) { + table := []struct { + changes [4]int + total float32 + exp bool + }{ + {[4]int{2, 100, 0, 0}, 14.11, false}, + {[4]int{0, 0, 20, 5}, 0.75, true}, + {[4]int{30, 40, 20, 5}, 12.55, true}, + {[4]int{10, 0, 0, 50}, 13.85, false}, + {[4]int{1, 0, 5, 219}, 19.99, false}, + {[4]int{1, 0, 2555, 219}, 127.75, true}, + {[4]int{1, 335, 0, 219}, 35.21, true}, + } + + for _, r := range table { + out := isChangeEnough(r.changes, r.total) + if out != r.exp { + t.Errorf("isChangeEnough(%v, %f) was incorrect, got: %t, expected: %t.", r.changes, r.total, out, r.exp) + } + } +} diff --git a/exercise2/problem10/README.md b/exercise2/problem10/README.md new file mode 100644 index 00000000..4b06247b --- /dev/null +++ b/exercise2/problem10/README.md @@ -0,0 +1,15 @@ +# Problem 10 + +Create a function that will return map of brands and how many of them and function to create a brand, which will return +incrementer of numbers of brands item. + +```go +brands, makeBrand := factory() +toyotaIncrementer := makeBrand("Toyota") +toyotaIncrementer(1) +toyotaIncrementer(2) +hyundaiIncrementer := makeBrand("Hyundai") +hyundaiIncrementer(5) +_ := makeBrand("Kia") +fmt.Println(brands) // map[Hyundai:5 Kia:0 Toyota:3] +``` diff --git a/exercise2/problem10/problem10.go b/exercise2/problem10/problem10.go new file mode 100644 index 00000000..552c787c --- /dev/null +++ b/exercise2/problem10/problem10.go @@ -0,0 +1,13 @@ +package problem10 + +func factory() (map[string]int, func(string2 string) func(int)) { + brands := make(map[string]int) + makeBrand := func(brandName string) func(int) { + brands[brandName] = 0 + return func(inc int) { + brands[brandName] = brands[brandName] + inc + } + } + + return brands, makeBrand +} diff --git a/exercise2/problem10/problem10_test.go b/exercise2/problem10/problem10_test.go new file mode 100644 index 00000000..b7835446 --- /dev/null +++ b/exercise2/problem10/problem10_test.go @@ -0,0 +1,22 @@ +package problem10 + +import ( + "maps" + "testing" +) + +func TestFactory(t *testing.T) { + brands, makeBrand := factory() + toyotaIncrementer := makeBrand("Toyota") + toyotaIncrementer(1) + toyotaIncrementer(2) + hyundaiIncrementer := makeBrand("Hyundai") + hyundaiIncrementer(5) + makeBrand("Kia") + + exp := map[string]int{"Hyundai": 5, "Kia": 0, "Toyota": 3} + + if !maps.Equal(brands, exp) { + t.Errorf("factory was incorrect, got: %v, expected: %v.", brands, exp) + } +} diff --git a/exercise2/problem11/README.md b/exercise2/problem11/README.md new file mode 100644 index 00000000..cf9e5402 --- /dev/null +++ b/exercise2/problem11/README.md @@ -0,0 +1,12 @@ +# Problem 11 + +Create a function that takes a slice of items, removes all duplicate items and returns a new slice in the +same sequential order as the old slice (minus duplicates). + +```go +removeDups([]int{1, 0, 1, 0}) // [1, 0] + +removeDups([]bool{true, false, false, true}) // ["The", "big", "cat"] + +removeDups([]string{"John", "Taylor", "John"}) // ["John", "Taylor"] +``` diff --git a/exercise2/problem11/problem11.go b/exercise2/problem11/problem11.go new file mode 100644 index 00000000..7ba96af7 --- /dev/null +++ b/exercise2/problem11/problem11.go @@ -0,0 +1,14 @@ +package problem11 + +func removeDups[T comparable](slice []T) []T { + list := make(map[T]struct{}) + result := make([]T, 0) + + for _, key := range slice { + if _, exists := list[key]; !exists { + list[key] = struct{}{} + result = append(result, key) + } + } + return result +} diff --git a/exercise2/problem11/problem11_test.go b/exercise2/problem11/problem11_test.go new file mode 100644 index 00000000..b47ad897 --- /dev/null +++ b/exercise2/problem11/problem11_test.go @@ -0,0 +1,40 @@ +package problem11 + +import ( + "slices" + "testing" +) + +type row[T comparable] struct { + inp []T + exp []T +} + +func TestRemoveDups(t *testing.T) { + row1 := row[int]{ + []int{1, 0, 1, 0}, + []int{1, 0}, + } + out1 := removeDups(row1.inp) + if !slices.Equal(out1, row1.exp) { + t.Errorf("removeDups(%v) was incorrect, got: %v, expected: %v.", row1.inp, out1, row1.exp) + } + + row2 := row[bool]{ + []bool{true, false, false, true}, + []bool{true, false}, + } + out2 := removeDups(row2.inp) + if !slices.Equal(out2, row2.exp) { + t.Errorf("removeDups(%v) was incorrect, got: %v, expected: %v.", row2.inp, out2, row2.exp) + } + + row3 := row[string]{ + []string{"John", "Taylor", "John"}, + []string{"John", "Taylor"}, + } + out3 := removeDups(row3.inp) + if !slices.Equal(out3, row3.exp) { + t.Errorf("removeDups(%v) was incorrect, got: %v, expected: %v.", row3.inp, out3, row3.exp) + } +} diff --git a/exercise2/problem12/README.md b/exercise2/problem12/README.md new file mode 100644 index 00000000..e3214066 --- /dev/null +++ b/exercise2/problem12/README.md @@ -0,0 +1,12 @@ +# Problem 12 + +Create a function that takes a map and returns the keys and values as separate slices. +Return the keys sorted alphabetically, and their corresponding values in the same order. + +```go +keysAndValues(map[string]int{"a": 1, "b": 2, "c": 3}) // []string{"a", "b", "c"}, []int{1, 2, 3} + +keysAndValues(map[string]string{"a": "Apple", "b": "Microsoft", "c": "Google"}) // []string{"a", "b", "c"}, []string{"Apple", "Microsoft", "Google"} + +keysAndValues(map[int]bool{1: true, 2: false, 3: false}, ) // []int{1, 2, 3}, []bool{true, false, false}, +``` diff --git a/exercise2/problem12/problem12.go b/exercise2/problem12/problem12.go new file mode 100644 index 00000000..d4f0dd45 --- /dev/null +++ b/exercise2/problem12/problem12.go @@ -0,0 +1,27 @@ +package problem11 + +func keysAndValues[T string | int, Y comparable](slice map[T]Y) ([]T, []Y) { + keys := make([]T, 0, len(slice)) + values := make([]Y, 0, len(slice)) + + for key := range slice { + keys = append(keys, key) + } + + // sort + for i := 0; i < len(keys); i++ { + for j := i + 1; j < len(keys); j++ { + if keys[i] > keys[j] { + temp := keys[j] + keys[j] = keys[i] + keys[i] = temp + } + } + } + + for _, key := range keys { + values = append(values, slice[key]) + } + + return keys, values +} diff --git a/exercise2/problem12/problem12_test.go b/exercise2/problem12/problem12_test.go new file mode 100644 index 00000000..05fbf828 --- /dev/null +++ b/exercise2/problem12/problem12_test.go @@ -0,0 +1,54 @@ +package problem11 + +import ( + "slices" + "testing" +) + +type row[K comparable, V any] struct { + inp map[K]V + exp1 []K + exp2 []V +} + +func TestKeysAndValues(t *testing.T) { + row1 := row[string, int]{ + map[string]int{"a": 1, "b": 2, "c": 3}, + []string{"a", "b", "c"}, + []int{1, 2, 3}, + } + out1_1, out1_2 := keysAndValues(row1.inp) + if !slices.Equal(out1_1, row1.exp1) || !slices.Equal(out1_2, row1.exp2) { + t.Errorf("keysAndValues(%v) was incorrect, got: %v, %v, expected: %v, %v.", row1.inp, out1_1, out1_2, row1.exp1, row1.exp2) + } + + row2 := row[string, string]{ + map[string]string{"a": "Apple", "b": "Microsoft", "c": "Google"}, + []string{"a", "b", "c"}, + []string{"Apple", "Microsoft", "Google"}, + } + out2_1, out2_2 := keysAndValues(row2.inp) + if !slices.Equal(out2_1, row2.exp1) || !slices.Equal(out2_2, row2.exp2) { + t.Errorf("keysAndValues(%v) was incorrect, got: %v, %v, expected: %v, %v.", row2.inp, out2_1, out2_2, row2.exp1, row2.exp2) + } + + row3 := row[int, bool]{ + map[int]bool{1: true, 2: false, 3: false}, + []int{1, 2, 3}, + []bool{true, false, false}, + } + out3_1, out3_2 := keysAndValues(row3.inp) + if !slices.Equal(out3_1, row3.exp1) || !slices.Equal(out3_2, row3.exp2) { + t.Errorf("keysAndValues(%v) was incorrect, got: %v, %v, expected: %v, %v.", row3.inp, out3_1, out3_2, row3.exp1, row3.exp2) + } + + row4 := row[int, bool]{ + nil, + []int{}, + []bool{}, + } + out4_1, out4_2 := keysAndValues(row4.inp) + if !slices.Equal(out4_1, row4.exp1) || !slices.Equal(out4_2, row4.exp2) { + t.Errorf("keysAndValues(%v) was incorrect, got: %v, %v, expected: %v, %v.", row4.inp, out4_1, out4_2, row4.exp1, row4.exp2) + } +} diff --git a/exercise2/problem2/README.md b/exercise2/problem2/README.md new file mode 100644 index 00000000..1bc0de13 --- /dev/null +++ b/exercise2/problem2/README.md @@ -0,0 +1,12 @@ +# Problem 2 + +Create a function that takes an array of names and returns an array where only the first letter of each name +is capitalized. + +```go +capitalize(["mavis", "senaida", "letty"]) // ["Mavis", "Senaida", "Letty"] + +capitalize(["samuel", "MABELLE", "letitia", "meridith"]) // ["Samuel", "Mabelle", "Letitia", "Meridith"] + +capitalize(["Slyvia", "Kristal", "Sharilyn", "Calista"]) // ["Slyvia", "Kristal", "Sharilyn", "Calista"] +``` diff --git a/exercise2/problem2/problem2.go b/exercise2/problem2/problem2.go new file mode 100644 index 00000000..fc8edc53 --- /dev/null +++ b/exercise2/problem2/problem2.go @@ -0,0 +1,36 @@ +package problem2 + +func capitalize(arr []string) []string { + if len(arr) < 1 { + return arr + } + for i, el := range arr { + if len(el) == 0 { + continue + } + arr[i] = toCapital(el) + } + + return arr +} + +func toCapital(s string) string { + runes := []rune(s) + for i, char := range runes { + if i == 0 { + if char >= 'a' && char <= 'z' { + runes[i] = char - 32 + continue + } + runes[i] = char + } else { + if char >= 'A' && char <= 'Z' { + runes[i] = char + 32 + continue + } + runes[i] = char + } + } + + return string(runes) +} diff --git a/exercise2/problem2/problem2_test.go b/exercise2/problem2/problem2_test.go new file mode 100644 index 00000000..85b8eb53 --- /dev/null +++ b/exercise2/problem2/problem2_test.go @@ -0,0 +1,29 @@ +package problem2 + +import ( + "slices" + "testing" +) + +func TestCapitalize(t *testing.T) { + table := []struct { + names []string + exp []string + }{ + {[]string{"A", "B", ""}, []string{"A", "B", ""}}, + {[]string{"mavis", "senaida", "letty"}, []string{"Mavis", "Senaida", "Letty"}}, + {[]string{"samuel", "MABELLE", "letitia", "meridith"}, []string{"Samuel", "Mabelle", "Letitia", "Meridith"}}, + {[]string{"Slyvia", "Kristal", "Sharilyn", "Calista"}, []string{"Slyvia", "Kristal", "Sharilyn", "Calista"}}, + {[]string{"krisTopher", "olIva", "herminiA"}, []string{"Kristopher", "Oliva", "Herminia"}}, + {[]string{"luke", "marsha", "stanford"}, []string{"Luke", "Marsha", "Stanford"}}, + {[]string{"kara"}, []string{"Kara"}}, + {[]string{"mARIANN", "jOI", "gEORGEANN"}, []string{"Mariann", "Joi", "Georgeann"}}, + } + + for _, r := range table { + capitalized := capitalize(r.names) + if !slices.Equal(r.exp, capitalized) { + t.Errorf("capitalize(%v) was incorrect, got: %v, expected: %v.", r.names, capitalized, r.exp) + } + } +} diff --git a/exercise2/problem3/README.md b/exercise2/problem3/README.md new file mode 100644 index 00000000..1fd25342 --- /dev/null +++ b/exercise2/problem3/README.md @@ -0,0 +1,33 @@ +# Problem 3 + +Write a function that diagonally orders numbers in a `n x n` matrix, depending on which of the four corners you +originate from: upper-left (`ul`), upper-right (`ur`), lower-left (`ll`), lower-right (`lr`). + +```go +diagonalize(3, "ul") ➞ [ +[0, 1, 2], +[1, 2, 3], +[2, 3, 4] +] + +diagonalize(4, "ur") ➞ [ +[3, 2, 1, 0], +[4, 3, 2, 1], +[5, 4, 3, 2], +[6, 5, 4, 3] +] + +diagonalize(3, "ll") ➞ [ +[2, 3, 4], +[1, 2, 3], +[0, 1, 2] +] + +diagonalize(5, "lr") ➞ [ +[8, 7, 6, 5, 4], +[7, 6, 5, 4, 3], +[6, 5, 4, 3, 2], +[5, 4, 3, 2, 1], +[4, 3, 2, 1, 0] +] +``` diff --git a/exercise2/problem3/problem3.go b/exercise2/problem3/problem3.go new file mode 100644 index 00000000..2a287eee --- /dev/null +++ b/exercise2/problem3/problem3.go @@ -0,0 +1,53 @@ +package main + +type dir string + +const ( + ul dir = "ul" + ur dir = "ur" + ll dir = "ll" + lr dir = "lr" +) + +func diagonalize(num int, corner dir) [][]int { + row := corner[0] + col := corner[1] + arr := make([][]int, num) + + if row == 'u' { + for i := 0; i < num; i++ { + arr[i] = make([]int, num) + if col == 'l' { + // ul + arr[i] = iteration(i, num, true) + continue + } + // ur + arr[i] = iteration(num+i-1, num, false) + } + } else { + for i := num - 1; i >= 0; i-- { + arr[i] = make([]int, num) + if col == 'l' { + // ll + arr[i] = iteration(num-1-i, num, true) + continue + } + // lr + arr[i] = iteration(num+num-2-i, num, false) + } + } + return arr +} + +func iteration(start int, size int, isAsc bool) []int { + arr := make([]int, size) + for i := 0; i < size; i++ { + if isAsc { + arr[i] = start + i + } else { + arr[i] = start - i + } + } + return arr +} diff --git a/exercise2/problem3/problem3_test.go b/exercise2/problem3/problem3_test.go new file mode 100644 index 00000000..92da6bb0 --- /dev/null +++ b/exercise2/problem3/problem3_test.go @@ -0,0 +1,72 @@ +package main + +import ( + "slices" + "testing" + + "github.com/talgat-ruby/exercises-go/pkg/util" +) + +func TestDiagonalize(t *testing.T) { + util.SkipTestOptional(t) + + table := []struct { + n int + d dir + exp [][]int + }{ + { + 3, + ul, + [][]int{ + {0, 1, 2}, + {1, 2, 3}, + {2, 3, 4}, + }, + }, + { + 4, + ur, + [][]int{ + {3, 2, 1, 0}, + {4, 3, 2, 1}, + {5, 4, 3, 2}, + {6, 5, 4, 3}, + }, + }, + { + 3, + ll, + [][]int{ + {2, 3, 4}, + {1, 2, 3}, + {0, 1, 2}, + }, + }, + { + 5, + lr, + [][]int{ + {8, 7, 6, 5, 4}, + {7, 6, 5, 4, 3}, + {6, 5, 4, 3, 2}, + {5, 4, 3, 2, 1}, + {4, 3, 2, 1, 0}, + }, + }, + } + + for _, r := range table { + matrix := diagonalize(r.n, r.d) + if len(matrix) != len(r.exp) { + t.Errorf("diagonalize(%d, %s) was incorrect, got: %v, expected: %v.", r.n, r.d, matrix, r.exp) + return + } + for i, row := range matrix { + if !slices.Equal(r.exp[i], row) { + t.Errorf("diagonalize(%d, %s) was incorrect, got: %v, expected: %v.", r.n, r.d, matrix, r.exp) + return + } + } + } +} diff --git a/exercise2/problem4/README.md b/exercise2/problem4/README.md new file mode 100644 index 00000000..e59bd0dd --- /dev/null +++ b/exercise2/problem4/README.md @@ -0,0 +1,14 @@ +# Problem 4 + +Write a function that creates an object with each (key, value) pair being the (lower case, upper case) versions +of a letter, respectively. + +All the letters in the input list will always be lowercase. + +```go +mapping(["p", "s"]) // { "p": "P", "s": "S" } + +mapping(["a", "b", "c"]) // { "a": "A", "b": "B", "c": "C" } + +mapping(["a", "v", "y", "z"]) // { "a": "A", "v": "V", "y": "Y", "z": "Z" } +``` diff --git a/exercise2/problem4/problem4.go b/exercise2/problem4/problem4.go new file mode 100644 index 00000000..557a2c79 --- /dev/null +++ b/exercise2/problem4/problem4.go @@ -0,0 +1,14 @@ +package problem4 + +func mapping(arr []string) map[string]string { + letterMap := make(map[string]string) + + for _, key := range arr { + letterMap[key] = toUpper(key) + } + return letterMap +} + +func toUpper(str string) string { + return string(rune(str[0]) - 32) +} diff --git a/exercise2/problem4/problem4_test.go b/exercise2/problem4/problem4_test.go new file mode 100644 index 00000000..d9c9ca18 --- /dev/null +++ b/exercise2/problem4/problem4_test.go @@ -0,0 +1,33 @@ +package problem4 + +import ( + "maps" + "testing" +) + +func TestMapping(t *testing.T) { + table := []struct { + inp []string + exp map[string]string + }{ + { + []string{"a", "b", "c"}, + map[string]string{"a": "A", "b": "B", "c": "C"}, + }, + { + []string{"p", "s", "t"}, + map[string]string{"p": "P", "s": "S", "t": "T"}, + }, + { + []string{"a", "v", "y", "z"}, + map[string]string{"a": "A", "v": "V", "y": "Y", "z": "Z"}, + }, + } + + for _, r := range table { + out := mapping(r.inp) + if !maps.Equal(out, r.exp) { + t.Errorf("mapping(%v) was incorrect, got: %v, expected: %v.", r.inp, out, r.exp) + } + } +} diff --git a/exercise2/problem5/README.md b/exercise2/problem5/README.md new file mode 100644 index 00000000..389cba40 --- /dev/null +++ b/exercise2/problem5/README.md @@ -0,0 +1,13 @@ +# Problem 5 + +You will be given a map with various consumer products with their respective prices and a price. +Return a list of the products with a minimum price of the provided one in ascending order. For the equal prices, +in ascending order of keys. + +```go +products({"Computer": 600, "TV": 800, "Radio": 50}, 300) ➞ ["TV", "Computer"] + +products({"Bike1": 510, "Bike2": 401, "Bike3": 501}, 500) ➞ ["Bike1", "Bike3"]) + +products({"Loafers": 50, "Vans": 10, "Crocs": 20}, 100) ➞ [] +``` diff --git a/exercise2/problem5/problem5.go b/exercise2/problem5/problem5.go new file mode 100644 index 00000000..8523ea00 --- /dev/null +++ b/exercise2/problem5/problem5.go @@ -0,0 +1,25 @@ +package problem5 + +func products(obj map[string]int, amount int) []string { + keys := make([]string, 0) + reversedKeys := make([]string, 0) + + for key, value := range obj { + if value > amount { + keys = append(keys, key) + } + } + + // sort + for i := 0; i < len(keys); i++ { + for j := i + 1; j < len(keys); j++ { + if obj[keys[i]] < obj[keys[j]] { + temp := keys[j] + keys[j] = keys[i] + keys[i] = temp + } + } + } + + return reversedKeys +} diff --git a/exercise2/problem5/problem5_test.go b/exercise2/problem5/problem5_test.go new file mode 100644 index 00000000..1bd2367a --- /dev/null +++ b/exercise2/problem5/problem5_test.go @@ -0,0 +1,47 @@ +package problem5 + +import ( + "slices" + "testing" +) + +func TestProducts(t *testing.T) { + table := []struct { + catalog map[string]int + minPrice int + exp []string + }{ + { + map[string]int{"Computer": 600, "TV": 800, "Radio": 100}, + 300, + []string{"TV", "Computer"}, + }, + { + map[string]int{"Bike1": 510, "Bike2": 401, "Bike3": 501}, + 500, + []string{"Bike1", "Bike3"}, + }, + { + map[string]int{"Calvin Klein": 2000, "Armani": 5000, "Dolce & Gabbana": 2000}, + 1000, + []string{"Armani", "Calvin Klein", "Dolce & Gabbana"}, + }, + { + map[string]int{"Loafers": 50, "Vans": 10, "Crocs": 20}, + 100, + []string{}, + }, + { + map[string]int{"Dell": 400, "HP": 300, "Apple": 400}, + 350, + []string{"Apple", "Dell"}, + }, + } + + for _, r := range table { + out := products(r.catalog, r.minPrice) + if !slices.Equal(out, r.exp) { + t.Errorf("products(%v, %d) was incorrect, got: %v, expected: %v.", r.catalog, r.minPrice, out, r.exp) + } + } +} diff --git a/exercise2/problem6/README.md b/exercise2/problem6/README.md new file mode 100644 index 00000000..0fd7be94 --- /dev/null +++ b/exercise2/problem6/README.md @@ -0,0 +1,16 @@ +# Problem 6 + +Given two unique integer slices `a` and `b`, and an integer target value `v`, +create a function to determine whether there is a pair of numbers that add up to the target value `v`, +where one number comes from one array `a` and the other comes from the second array `b`. +Return `true` if there is a pair that adds up to the target value and `false` otherwise. + +```go +sumOfTwo([1, 2], [4, 5, 6], 5) // true + +sumOfTwo([1, 2], [4, 5, 6], 8) // true + +sumOfTwo([1, 2], [4, 5, 6], 3) // false + +sumOfTwo([1, 2], [4, 5, 6], 9) // false +``` diff --git a/exercise2/problem6/problem6.go b/exercise2/problem6/problem6.go new file mode 100644 index 00000000..98414159 --- /dev/null +++ b/exercise2/problem6/problem6.go @@ -0,0 +1,12 @@ +package problem6 + +func sumOfTwo(arr1 []int, arr2 []int, target int) bool { + for _, el1 := range arr2 { + for _, el2 := range arr1 { + if el1+el2 == target { + return true + } + } + } + return false +} diff --git a/exercise2/problem6/problem6_test.go b/exercise2/problem6/problem6_test.go new file mode 100644 index 00000000..2334db9e --- /dev/null +++ b/exercise2/problem6/problem6_test.go @@ -0,0 +1,80 @@ +package problem6 + +import ( + "testing" + + "github.com/talgat-ruby/exercises-go/pkg/util" +) + +func TestSumOfTwo(t *testing.T) { + util.SkipTestOptional(t) + + table := []struct { + a []int + b []int + sum int + exp bool + }{ + { + []int{1, 2, 3}, + []int{10, 20, 30, 40, 50}, + 42, + true, + }, + { + []int{1, 2, 3}, + []int{10, 20, 30, 40, 50}, + 11, + true, + }, + { + []int{1, 2, 3}, + []int{10, 20, 30, 40, 50}, + 60, + false, + }, + { + []int{1, 2, 3}, + []int{10, 20, 30, 40, 50}, + 53, + true, + }, + { + []int{1, 2, 3}, + []int{10, 20, 30, 40, 50}, + 4, + false, + }, + { + []int{1, 2}, + []int{4, 5, 6}, + 5, + true, + }, + { + []int{1, 2}, + []int{4, 5, 6}, + 8, + true, + }, + { + []int{1, 2}, + []int{4, 5, 6}, + 3, + false, + }, + { + []int{1, 2}, + []int{4, 5, 6}, + 9, + false, + }, + } + + for _, r := range table { + out := sumOfTwo(r.a, r.b, r.sum) + if out != r.exp { + t.Errorf("sumOfTwo(%v, %v, %d) was incorrect, got: %t, expected: %t.", r.a, r.b, r.sum, out, r.exp) + } + } +} diff --git a/exercise2/problem7/README.md b/exercise2/problem7/README.md new file mode 100644 index 00000000..97b45dab --- /dev/null +++ b/exercise2/problem7/README.md @@ -0,0 +1,9 @@ +# Problem 7 + +Swap the `int` values through pointers. + +```go +a, b := 1, 2 +swap(&a, &b) +fmt.Println(a, b) // 2, 1 +``` diff --git a/exercise2/problem7/problem7.go b/exercise2/problem7/problem7.go new file mode 100644 index 00000000..313c99f4 --- /dev/null +++ b/exercise2/problem7/problem7.go @@ -0,0 +1,7 @@ +package problem7 + +func swap(a *int, b *int) { + temp := *a + *a = *b + *b = temp +} diff --git a/exercise2/problem7/problem7_test.go b/exercise2/problem7/problem7_test.go new file mode 100644 index 00000000..169dd39a --- /dev/null +++ b/exercise2/problem7/problem7_test.go @@ -0,0 +1,14 @@ +package problem7 + +import ( + "testing" +) + +func TestSwap(t *testing.T) { + a, b := 1, 2 + outA, outB := b, a + swap(&a, &b) + if a != outA && b != outB { + t.Errorf("swap was incorrect, got: %d, %d expected: %d, %d.", a, b, outA, outB) + } +} diff --git a/exercise2/problem8/README.md b/exercise2/problem8/README.md new file mode 100644 index 00000000..1ef6b8a4 --- /dev/null +++ b/exercise2/problem8/README.md @@ -0,0 +1,10 @@ +# Problem 8 + +Simplify the given code using your knowledge of map, slices, and pointers. Don't remove `load`, update it. +The output should be the same + +```go +simplify([]string{"a", "b", "c"}) // map[a:0 b:1 c:2] + +simplify([]string{"z", "y", "x", "u", "v"}) // map[u:3 v:4 x:2 y:1 z:0] +``` diff --git a/exercise2/problem8/problem8.go b/exercise2/problem8/problem8.go new file mode 100644 index 00000000..7cad8b0a --- /dev/null +++ b/exercise2/problem8/problem8.go @@ -0,0 +1,14 @@ +package problem8 + +func simplify(list []string) map[string]int { + var indMap map[string]int + indMap = make(map[string]int) + load(indMap, list) + return indMap +} + +func load(m map[string]int, students []string) { + for i, name := range students { + m[name] = i + } +} diff --git a/exercise2/problem8/problem8_test.go b/exercise2/problem8/problem8_test.go new file mode 100644 index 00000000..b73749c2 --- /dev/null +++ b/exercise2/problem8/problem8_test.go @@ -0,0 +1,35 @@ +package problem8 + +import ( + "maps" + "reflect" + "testing" +) + +func TestSimplify(t *testing.T) { + table := []struct { + inp []string + exp map[string]int + }{ + { + []string{"a", "b", "c"}, + map[string]int{"a": 0, "b": 1, "c": 2}, + }, + { + []string{"z", "y", "x", "u", "v"}, + map[string]int{"z": 0, "y": 1, "x": 2, "u": 3, "v": 4}, + }, + } + + for _, r := range table { + out := simplify(r.inp) + if !maps.Equal(out, r.exp) { + t.Errorf("simplify(%v) was incorrect, got: %v, expected: %v.", r.inp, out, r.exp) + } + } + + tp := reflect.TypeOf(load) + if tp.Kind() != reflect.Func || tp.NumOut() != 0 || tp.NumIn() != 2 || tp.In(0).Kind() == reflect.Pointer || tp.In(1).Kind() == reflect.Pointer { + t.Errorf("load wrong format") + } +} diff --git a/exercise2/problem9/README.md b/exercise2/problem9/README.md new file mode 100644 index 00000000..1fd86b8b --- /dev/null +++ b/exercise2/problem9/README.md @@ -0,0 +1,13 @@ +# Problem 9 + +Create a function that takes an `int` as its parameter and returns another function. +The returned function must take a as many as needed `int` as its parameter, and return a `slice` of the `int` +multiplied by the `int` that was passed into the first function. + +```go +first := factory(15) +first(2, 3, 4) // [30, 45, 60] + +second := factory(2) +second(1, 2, 3, 4) // [2, 4, 6, 8] +``` diff --git a/exercise2/problem9/problem9.go b/exercise2/problem9/problem9.go new file mode 100644 index 00000000..ddab5d8f --- /dev/null +++ b/exercise2/problem9/problem9.go @@ -0,0 +1,10 @@ +package problem9 + +func factory(num int) func(...int) []int { + return func(args ...int) []int { + for i, arg := range args { + args[i] = num * arg + } + return args + } +} diff --git a/exercise2/problem9/problem9_test.go b/exercise2/problem9/problem9_test.go new file mode 100644 index 00000000..1f18e101 --- /dev/null +++ b/exercise2/problem9/problem9_test.go @@ -0,0 +1,53 @@ +package problem9 + +import ( + "slices" + "testing" +) + +func TestFactory(t *testing.T) { + table := []struct { + multiple int + list []int + exp []int + }{ + { + 15, + []int{2, 3, 4}, + []int{30, 45, 60}, + }, + { + 2, + []int{1, 2, 3}, + []int{2, 4, 6}, + }, + { + 6, + []int{10, 5}, + []int{60, 30}, + }, + { + 7, + []int{2, 3, 7}, + []int{14, 21, 49}, + }, + { + 5, + []int{2, 1, 4}, + []int{10, 5, 20}, + }, + { + 10, + []int{10, 1, 6}, + []int{100, 10, 60}, + }, + } + + for _, r := range table { + curry := factory(r.multiple) + out := curry(r.list...) + if !slices.Equal(out, r.exp) { + t.Errorf("factory(%d)(%v) was incorrect, got: %v, expected: %v.", r.multiple, r.list, out, r.exp) + } + } +} From e820fa5588030ac5e888e8f88a1456709602c718 Mon Sep 17 00:00:00 2001 From: turaroay Date: Wed, 9 Oct 2024 08:59:38 +0500 Subject: [PATCH 12/15] exercise 3 --- exercise3/problem1/problem1.go | 36 +++++++++- exercise3/problem2/problem2.go | 37 +++++++++- exercise3/problem3/problem3.go | 123 ++++++++++++++++++++++++++++++++- exercise3/problem4/problem4.go | 106 +++++++++++++++++++++++++++- exercise3/problem5/problem5.go | 19 ++++- exercise3/problem6/problem6.go | 30 +++++++- exercise3/problem7/problem7.go | 55 +++++++++++++++ 7 files changed, 398 insertions(+), 8 deletions(-) diff --git a/exercise3/problem1/problem1.go b/exercise3/problem1/problem1.go index d45605c6..1886fb53 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 { + array []any +} + +func (queue *Queue) Enqueue(element any) { + queue.array = append(queue.array, element) +} + +func (queue *Queue) Dequeue() (any, error) { + if queue.IsEmpty() { + var zeroValue any + return zeroValue, errors.New("queue is empty") + } + val := queue.array[0] + queue.array = queue.array[1:] + return val, nil +} + +func (queue *Queue) Peek() (any, error) { + if queue.IsEmpty() { + var zeroValue any + return zeroValue, errors.New("queue is empty") + } + return queue.array[0], nil +} + +func (queue *Queue) Size() int { + return len(queue.array) +} + +func (queue *Queue) IsEmpty() bool { + return queue.Size() == 0 +} diff --git a/exercise3/problem2/problem2.go b/exercise3/problem2/problem2.go index e9059889..c946c182 100644 --- a/exercise3/problem2/problem2.go +++ b/exercise3/problem2/problem2.go @@ -1,3 +1,38 @@ package problem2 -type Stack struct{} +import "errors" + +type Stack struct { + array []any +} + +func (stack *Stack) Push(element any) { + stack.array = append(stack.array, element) +} + +func (stack *Stack) Pop() (any, error) { + if stack.IsEmpty() { + var zeroValue any + return zeroValue, errors.New("queue is empty") + } + lastIndex := stack.Size() - 1 + val := stack.array[lastIndex] + stack.array = stack.array[:lastIndex] + return val, nil +} + +func (stack *Stack) Peek() (any, error) { + if stack.IsEmpty() { + var zeroValue any + return zeroValue, errors.New("queue is empty") + } + return stack.array[stack.Size()-1], nil +} + +func (stack *Stack) Size() int { + return len(stack.array) +} + +func (stack *Stack) IsEmpty() bool { + return stack.Size() == 0 +} diff --git a/exercise3/problem3/problem3.go b/exercise3/problem3/problem3.go index d8d79ac0..a5642d82 100644 --- a/exercise3/problem3/problem3.go +++ b/exercise3/problem3/problem3.go @@ -1,3 +1,124 @@ package problem3 -type Set struct{} +type Set struct { + array []any +} + +func NewSet() *Set { + return &Set{} +} + +func (set *Set) Add(input any) { + isDuplicate := false + for _, val := range set.array { + if val == input { + isDuplicate = true + } + } + + if isDuplicate == false { + set.array = append(set.array, input) + } +} + +func (set *Set) Remove(input any) { + var newArray []any + for _, val := range set.array { + if val != input { + newArray = append(newArray, val) + } + } + set.array = newArray +} + +func (set *Set) IsEmpty() bool { + return len(set.array) == 0 +} + +func (set *Set) Size() int { + return len(set.array) +} + +func (set *Set) List() []any { + return set.array +} + +func (set *Set) Has(input any) bool { + for _, val := range set.array { + if val == input { + return true + } + } + return false +} + +func (set *Set) Copy() *Set { + newSet := NewSet() + for _, val := range set.array { + newSet.Add(val) + } + return newSet +} + +func (set *Set) Difference(s2 *Set) *Set { + resultSet := NewSet() + for _, val := range set.array { + var flag bool + for _, v2 := range s2.array { + if val == v2 { + flag = true + break + } + } + + if !flag { + resultSet.Add(val) + } + } + return resultSet +} + +func (set *Set) IsSubset(s2 *Set) bool { + for _, val := range set.array { + var flag bool + for _, v2 := range s2.array { + if val == v2 { + flag = true + break + } + } + + if !flag { + return false + } + } + return true +} + +func Union(sets ...*Set) *Set { + resultSet := NewSet() + for _, set := range sets { + for _, val := range set.array { + resultSet.Add(val) + } + } + return resultSet +} + +func Intersect(sets ...*Set) *Set { + result := NewSet() + for _, val := range sets[0].array { + result.Add(val) + } + + for _, set := range sets[1:] { + temp := NewSet() + for _, val := range result.array { + if set.Has(val) { + temp.Add(val) + } + } + result = temp + } + return result +} diff --git a/exercise3/problem4/problem4.go b/exercise3/problem4/problem4.go index ebf78147..ccc7dcb1 100644 --- a/exercise3/problem4/problem4.go +++ b/exercise3/problem4/problem4.go @@ -1,3 +1,107 @@ package problem4 -type LinkedList struct{} +import ( + "errors" +) + +type Element[T comparable] struct { + value T + next *Element[T] + prev *Element[T] +} + +type LinkedList[T comparable] struct { + head *Element[T] +} + +func (list *LinkedList[T]) Add(el *Element[T]) { + if list.head == nil { + list.head = el + return + } + + current := list.head + for current.next != nil { + current = current.next + } + current.next = el +} + +func (list *LinkedList[T]) Insert(el *Element[T], index int) error { + if index < 0 || index > list.Size() { + return errors.New("index out of range") + } + + idx := 1 + current := list.head + + for current.next != nil { + idx++ + if idx == index { + temp := current.next + current.next = el + current.next.next = temp + return nil + } + current = current.next + } + return nil +} + +func (list *LinkedList[T]) Delete(el *Element[T]) error { + if list.head.value == el.value { + list.head = list.head.next + return nil + } + + if list.head == nil { + return errors.New("list is empty") + } + + current := list.head + for current.next != nil { + if current.next.value == el.value { + current.next = current.next.next + return nil + } + current = current.next + } + return errors.New("not found") +} + +func (list *LinkedList[T]) Find(value any) (*Element[T], error) { + if list.head.value == value { + return list.head, nil + } + + current := list.head + for current.next != nil { + if current.next.value == value { + return current.next, nil + } + current = current.next + } + return &Element[T]{}, errors.New("not found") +} + +func (list *LinkedList[T]) List() []T { + var result []T + if list.head == nil { + return result + } + current := list.head + result = append(result, current.value) + for current.next != nil { + current = current.next + result = append(result, current.value) + } + return result +} + +func (list *LinkedList[T]) Size() int { + return len(list.List()) +} + +func (list *LinkedList[T]) IsEmpty() bool { + return list.Size() == 0 +} diff --git a/exercise3/problem5/problem5.go b/exercise3/problem5/problem5.go index 4177599f..3b2d84c5 100644 --- a/exercise3/problem5/problem5.go +++ b/exercise3/problem5/problem5.go @@ -1,3 +1,20 @@ package problem5 -type Person struct{} +import "fmt" + +type Person struct { + name string + age int +} + +func (person Person) compareAge(p *Person) string { + if person.age < p.age { + return fmt.Sprintf("%s is older than me.", p.name) + } + + if person.age > p.age { + return fmt.Sprintf("%s is younger than me.", p.name) + } + + return fmt.Sprintf("%s is the same age as me.", p.name) +} diff --git a/exercise3/problem6/problem6.go b/exercise3/problem6/problem6.go index 4e8d1af8..20530bd6 100644 --- a/exercise3/problem6/problem6.go +++ b/exercise3/problem6/problem6.go @@ -1,7 +1,31 @@ package problem6 -type Animal struct{} +type Animal struct { + name string + legsNum int +} -type Insect struct{} +type Insect struct { + name string + legsNum int +} -func sumOfAllLegsNum() {} +type Legs interface { + getLegsCount() int +} + +func (animal *Animal) getLegsCount() int { + return animal.legsNum +} + +func (insect *Insect) getLegsCount() int { + return insect.legsNum +} + +func sumOfAllLegsNum(inputs ...Legs) int { + total := 0 + for _, el := range inputs { + total += el.getLegsCount() + } + return total +} diff --git a/exercise3/problem7/problem7.go b/exercise3/problem7/problem7.go index 26887151..5ad84b68 100644 --- a/exercise3/problem7/problem7.go +++ b/exercise3/problem7/problem7.go @@ -1,10 +1,65 @@ package problem7 +import "fmt" + type BankAccount struct { + name string + balance int } type FedexAccount struct { + name string + packages []string } type KazPostAccount struct { + name string + balance int + packages []string +} + +type setBalance interface { + setBalance(balance int) +} + +type sendTo interface { + getName() string + addPackage(pkg string) +} + +func (account *BankAccount) setBalance(amount int) { + account.balance = account.balance - amount +} + +func (account *FedexAccount) getName() string { + return account.name +} + +func (account *FedexAccount) addPackage(pkg string) { + account.packages = append(account.packages, pkg) +} + +func (account *KazPostAccount) getName() string { + return account.name +} + +func (account *KazPostAccount) setBalance(amount int) { + account.balance = account.balance - amount +} + +func (account *KazPostAccount) addPackage(pkg string) { + account.packages = append(account.packages, pkg) +} + +func withdrawMoney(money int, accounts ...setBalance) { + for _, acc := range accounts { + acc.setBalance(money) + } +} + +func sendPackagesTo(whom string, accounts ...sendTo) { + for _, acc := range accounts { + toWhom := fmt.Sprintf("%s send package to %s", acc.getName(), whom) + acc.addPackage(toWhom) + } } From 66822ce6806909a2c020dfe7c9808c2e6a723e7f Mon Sep 17 00:00:00 2001 From: turaroay Date: Mon, 4 Nov 2024 14:24:53 +0800 Subject: [PATCH 13/15] exercise: 5. problem2.go in progress --- exercise5/problem1/problem1.go | 3 +++ exercise5/problem3/problem3.go | 4 +++- exercise5/problem4/problem4.go | 1 + exercise5/problem5/problem5.go | 18 ++++++++++++++++-- exercise5/problem6/problem6.go | 29 ++++++++++++++++++++++++++--- exercise5/problem7/problem7.go | 27 ++++++++++++++++++++++++++- exercise5/problem8/problem8.go | 12 +++++++++++- 7 files changed, 86 insertions(+), 8 deletions(-) diff --git a/exercise5/problem1/problem1.go b/exercise5/problem1/problem1.go index 4f514fab..11bd82cb 100644 --- a/exercise5/problem1/problem1.go +++ b/exercise5/problem1/problem1.go @@ -1,9 +1,12 @@ package problem1 func incrementConcurrently(num int) int { + ch := make(chan int) go func() { num++ + ch <- num }() + <-ch return num } diff --git a/exercise5/problem3/problem3.go b/exercise5/problem3/problem3.go index e085a51a..d4787d4a 100644 --- a/exercise5/problem3/problem3.go +++ b/exercise5/problem3/problem3.go @@ -2,10 +2,12 @@ package problem3 func sum(a, b int) int { var c int + ch := make(chan int) go func(a, b int) { c = a + b + ch <- c }(a, b) - + <-ch return c } diff --git a/exercise5/problem4/problem4.go b/exercise5/problem4/problem4.go index b5899ddf..6c05ac2e 100644 --- a/exercise5/problem4/problem4.go +++ b/exercise5/problem4/problem4.go @@ -4,6 +4,7 @@ func iter(ch chan<- int, nums []int) { for _, n := range nums { ch <- n } + close(ch) } func sum(nums []int) int { diff --git a/exercise5/problem5/problem5.go b/exercise5/problem5/problem5.go index ac192c58..4195349f 100644 --- a/exercise5/problem5/problem5.go +++ b/exercise5/problem5/problem5.go @@ -1,8 +1,22 @@ package problem5 -func producer() {} +import "strings" -func consumer() {} +func producer(words []string, ch chan<- string) { + for _, i := range words { + ch <- i + } + close(ch) +} + +func consumer(ch <-chan string) string { + words := "" + + for i := range ch { + words += i + " " + } + return strings.TrimSpace(words) +} func send( words []string, diff --git a/exercise5/problem6/problem6.go b/exercise5/problem6/problem6.go index e1beea87..8ce1333e 100644 --- a/exercise5/problem6/problem6.go +++ b/exercise5/problem6/problem6.go @@ -2,8 +2,31 @@ package problem6 type pipe func(in <-chan int) <-chan int -var multiplyBy2 pipe = func() {} +var multiplyBy2 pipe = func(in <-chan int) <-chan int { + result := make(chan int) + go func() { + defer close(result) + for val := range in { + result <- val * 2 + } + }() + return result +} -var add5 pipe = func() {} +var add5 pipe = func(in <-chan int) <-chan int { + result := make(chan int) + go func() { + defer close(result) + for val := range in { + result <- val + 5 + } + }() + return result +} -func piper(in <-chan int, pipes []pipe) <-chan int {} +func piper(in <-chan int, pipes []pipe) <-chan int { + for _, p := range pipes { + in = p(in) + } + return in +} diff --git a/exercise5/problem7/problem7.go b/exercise5/problem7/problem7.go index c3c1d0c9..3f354ce8 100644 --- a/exercise5/problem7/problem7.go +++ b/exercise5/problem7/problem7.go @@ -1,3 +1,28 @@ package problem7 -func multiplex(ch1 <-chan string, ch2 <-chan string) []string {} +func multiplex(ch1 <-chan string, ch2 <-chan string) []string { + var result []string + + for { + select { + case val, ok := <-ch1: + if ok { + result = append(result, val) + break + } + ch1 = nil + case val, ok := <-ch2: + if ok { + result = append(result, val) + break + } + ch2 = nil + } + + if ch1 == nil && ch2 == nil { + break + } + } + + return result +} diff --git a/exercise5/problem8/problem8.go b/exercise5/problem8/problem8.go index 3e951b3b..a68ea42f 100644 --- a/exercise5/problem8/problem8.go +++ b/exercise5/problem8/problem8.go @@ -4,4 +4,14 @@ import ( "time" ) -func withTimeout(ch <-chan string, ttl time.Duration) string {} +func withTimeout(ch <-chan string, ttl time.Duration) string { + select { + case message, ok := <-ch: + if ok { + return message + } + case <-time.After(ttl): + break + } + return "timeout" +} From a5ad2401446fd4138f427376328331f1d29f9fdc Mon Sep 17 00:00:00 2001 From: turaroay Date: Mon, 18 Nov 2024 11:07:16 +0400 Subject: [PATCH 14/15] exercise: 6 --- exercise6/problem1/problem1.go | 22 ++++++++++++++++++++-- exercise6/problem2/problem2.go | 29 +++++++++++++++++++++++++---- exercise6/problem3/problem3.go | 14 ++++++++++++++ exercise6/problem4/problem4.go | 27 +++++++++++++++++---------- exercise6/problem5/problem5.go | 25 ++++++++++++++++--------- exercise6/problem6/problem6.go | 11 ++++++----- exercise6/problem7/problem7.go | 21 ++++++++++++++++----- exercise6/problem8/problem8.go | 29 ++++++++++++++++++++++++++++- go.mod | 2 +- 9 files changed, 143 insertions(+), 37 deletions(-) diff --git a/exercise6/problem1/problem1.go b/exercise6/problem1/problem1.go index ee453b24..e3816362 100644 --- a/exercise6/problem1/problem1.go +++ b/exercise6/problem1/problem1.go @@ -1,9 +1,27 @@ package problem1 +import "sync" + type bankAccount struct { - blnc int + blnc int + mutex sync.Mutex } func newAccount(blnc int) *bankAccount { - return &bankAccount{blnc} + return &bankAccount{blnc: blnc} +} + +func (acc *bankAccount) deposit(amount int) { + acc.mutex.Lock() + defer acc.mutex.Unlock() + acc.blnc += amount +} + +func (acc *bankAccount) withdraw(amount int) { + acc.mutex.Lock() + defer acc.mutex.Unlock() + + if acc.blnc >= amount { + acc.blnc -= amount + } } diff --git a/exercise6/problem2/problem2.go b/exercise6/problem2/problem2.go index 97e02368..12b70bb1 100644 --- a/exercise6/problem2/problem2.go +++ b/exercise6/problem2/problem2.go @@ -1,20 +1,41 @@ package problem2 import ( + "sync" "time" ) var readDelay = 10 * time.Millisecond type bankAccount struct { - blnc int + blnc int + mutex sync.Mutex } func newAccount(blnc int) *bankAccount { - return &bankAccount{blnc} + return &bankAccount{blnc: blnc} } -func (b *bankAccount) balance() int { +func (acc *bankAccount) deposit(amount int) { + acc.mutex.Lock() + defer acc.mutex.Unlock() + + acc.blnc += amount +} + +func (acc *bankAccount) withdraw(amount int) { + acc.mutex.Lock() + defer acc.mutex.Unlock() + + if acc.blnc >= amount { + acc.blnc -= amount + } +} + +func (acc *bankAccount) balance() int { time.Sleep(readDelay) - return 0 + acc.mutex.Lock() + defer acc.mutex.Unlock() + + return acc.blnc } diff --git a/exercise6/problem3/problem3.go b/exercise6/problem3/problem3.go index b34b90bb..acb946e2 100644 --- a/exercise6/problem3/problem3.go +++ b/exercise6/problem3/problem3.go @@ -1,5 +1,7 @@ package problem3 +import "sync/atomic" + type counter struct { val int64 } @@ -9,3 +11,15 @@ func newCounter() *counter { val: 0, } } + +func (c *counter) inc() { + atomic.AddInt64(&c.val, 1) +} + +func (c *counter) dec() { + atomic.AddInt64(&c.val, -1) +} + +func (c *counter) value() int64 { + return atomic.LoadInt64(&c.val) +} diff --git a/exercise6/problem4/problem4.go b/exercise6/problem4/problem4.go index 793449c9..0a1ef300 100644 --- a/exercise6/problem4/problem4.go +++ b/exercise6/problem4/problem4.go @@ -1,30 +1,37 @@ package problem4 import ( + "sync" "time" ) -func worker(id int, _ *[]string, ch chan<- int) { - // TODO wait for shopping list to be completed +var listCond = sync.NewCond(&sync.Mutex{}) +var listFilled bool + +func worker(id int, shoppingList *[]string, ch chan<- int) { + listCond.L.Lock() + for !listFilled { + listCond.Wait() + } ch <- id + listCond.L.Unlock() } func updateShopList(shoppingList *[]string) { time.Sleep(10 * time.Millisecond) - - *shoppingList = append(*shoppingList, "apples") - *shoppingList = append(*shoppingList, "milk") - *shoppingList = append(*shoppingList, "bake soda") + *shoppingList = append(*shoppingList, "apples", "milk", "bake soda") + listCond.L.Lock() + listFilled = true + listCond.Signal() + listCond.L.Unlock() } func notifyOnShopListUpdate(shoppingList *[]string, numWorkers int) <-chan int { notifier := make(chan int) - - for i := range numWorkers { + for i := 0; i < numWorkers; i++ { go worker(i+1, shoppingList, notifier) - time.Sleep(time.Millisecond) // order matters + time.Sleep(time.Millisecond) } - go updateShopList(shoppingList) return notifier diff --git a/exercise6/problem5/problem5.go b/exercise6/problem5/problem5.go index 8e4a1703..e9249e6a 100644 --- a/exercise6/problem5/problem5.go +++ b/exercise6/problem5/problem5.go @@ -1,30 +1,37 @@ package problem5 import ( + "sync" "time" ) +var listCond = sync.NewCond(&sync.Mutex{}) +var listFilled bool + func worker(id int, shoppingList *[]string, ch chan<- int) { - // TODO wait for shopping list to be completed + listCond.L.Lock() + for !listFilled { + listCond.Wait() + } ch <- id + listCond.L.Unlock() } func updateShopList(shoppingList *[]string) { time.Sleep(10 * time.Millisecond) - - *shoppingList = append(*shoppingList, "apples") - *shoppingList = append(*shoppingList, "milk") - *shoppingList = append(*shoppingList, "bake soda") + *shoppingList = append(*shoppingList, "apples", "milk", "bake soda") + listCond.L.Lock() + listFilled = true + listCond.Broadcast() + listCond.L.Unlock() } func notifyOnShopListUpdate(shoppingList *[]string, numWorkers int) <-chan int { notifier := make(chan int) - - for i := range numWorkers { + for i := 0; i < numWorkers; i++ { go worker(i+1, shoppingList, notifier) - time.Sleep(time.Millisecond) // order matters + time.Sleep(time.Millisecond) } - go updateShopList(shoppingList) return notifier diff --git a/exercise6/problem6/problem6.go b/exercise6/problem6/problem6.go index 0c1122b9..4fdac898 100644 --- a/exercise6/problem6/problem6.go +++ b/exercise6/problem6/problem6.go @@ -4,17 +4,18 @@ import ( "sync" ) +var once sync.Once + func runTasks(init func()) { var wg sync.WaitGroup - - for range 10 { + count := 0 + for count < 10 { wg.Add(1) go func() { defer wg.Done() - - //TODO: modify so that load function gets called only once. - init() + once.Do(init) }() + count++ } wg.Wait() } diff --git a/exercise6/problem7/problem7.go b/exercise6/problem7/problem7.go index ef49497b..d85e5275 100644 --- a/exercise6/problem7/problem7.go +++ b/exercise6/problem7/problem7.go @@ -1,18 +1,29 @@ package problem7 import ( - "fmt" "math/rand" + "sync" "time" ) +var ( + once sync.Once + initLock sync.Mutex + initRun int +) + +func initConf() { + initLock.Lock() + defer initLock.Unlock() + initRun++ +} + func task() { - start := time.Now() + once.Do(initConf) + var t *time.Timer t = time.AfterFunc( - randomDuration(), - func() { - fmt.Println(time.Now().Sub(start)) + randomDuration(), func() { t.Reset(randomDuration()) }, ) diff --git a/exercise6/problem8/problem8.go b/exercise6/problem8/problem8.go index 949eb2d2..10111543 100644 --- a/exercise6/problem8/problem8.go +++ b/exercise6/problem8/problem8.go @@ -1,3 +1,30 @@ package problem8 -func multiplex(chs []<-chan string) []string {} +import "sync" + +func multiplex(chs []<-chan string) []string { + var wg sync.WaitGroup + results := make([]string, 0) + resultCh := make(chan string) + + for _, ch := range chs { + wg.Add(1) + go func(ch <-chan string) { + defer wg.Done() + for msg := range ch { + resultCh <- msg + } + }(ch) + } + + go func() { + wg.Wait() + close(resultCh) + }() + + for msg := range resultCh { + results = append(results, msg) + } + + return results +} diff --git a/go.mod b/go.mod index 4cba3d61..19245ec7 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/talgat-ruby/exercises-go -go 1.23 +go 1.22.3 \ No newline at end of file From a49321c87ff00267903a3942f9627d78268aae1d Mon Sep 17 00:00:00 2001 From: aizada Date: Thu, 9 Jan 2025 16:31:24 +0500 Subject: [PATCH 15/15] exercise 7 and blogging project with auth --- exercise7/blogging-platform/.dockerignore | 32 +++++ exercise7/blogging-platform/.gitignore | 28 ++++ exercise7/blogging-platform/Dockerfile | 78 +++++++++++ exercise7/blogging-platform/compose.yaml | 70 ++++++++++ exercise7/blogging-platform/go.mod | 10 +- exercise7/blogging-platform/go.sum | 2 + .../internal/api/handler/auth/access_token.go | 114 ++++++++++++++++ .../internal/api/handler/auth/login.go | 123 ++++++++++++++++++ .../internal/api/handler/auth/main.go | 28 ++++ .../internal/api/handler/auth/register.go | 107 +++++++++++++++ .../internal/api/handler/main.go | 20 +++ .../internal/api/handler/posts/create_post.go | 96 ++++++++++++++ .../internal/api/handler/posts/delete_post.go | 57 ++++++++ .../internal/api/handler/posts/find_post.go | 67 ++++++++++ .../internal/api/handler/posts/find_posts.go | 71 ++++++++++ .../internal/api/handler/posts/main.go | 19 +++ .../internal/api/handler/posts/update_post.go | 76 +++++++++++ .../blogging-platform/internal/api/main.go | 57 ++++++++ .../internal/api/middleware/authenticator.go | 69 ++++++++++ .../internal/api/middleware/main.go | 17 +++ .../internal/api/router/auth.go | 12 ++ .../internal/api/router/main.go | 32 +++++ .../internal/api/router/posts.go | 14 ++ .../blogging-platform/internal/auth/hash.go | 71 ++++++++++ .../blogging-platform/internal/auth/main.go | 11 ++ .../blogging-platform/internal/auth/tokens.go | 83 ++++++++++++ .../internal/cli/constants.go | 3 + .../blogging-platform/internal/cli/main.go | 40 ++++++ .../blogging-platform/internal/cli/subcmd.go | 7 + .../internal/cli/subcmdSeed.go | 51 ++++++++ .../internal/db/auth/access_token.go | 45 +++++++ .../internal/db/auth/login.go | 48 +++++++ .../internal/db/auth/main.go | 18 +++ .../internal/db/auth/model.go | 14 ++ .../internal/db/auth/register.go | 40 ++++++ .../blogging-platform/internal/db/init.go | 40 ++++++ .../blogging-platform/internal/db/main.go | 56 ++++++++ .../20241205151103_create_post_table.down.sql | 1 + .../20241205151103_create_post_table.up.sql | 8 ++ .../20241219144054_create_user_table.down.sql | 1 + .../20241219144054_create_user_table.up.sql | 8 ++ .../internal/db/post/create_post.go | 45 +++++++ .../internal/db/post/delete_post.go | 35 +++++ .../internal/db/post/find_post.go | 39 ++++++ .../internal/db/post/find_posts.go | 52 ++++++++ .../internal/db/post/main.go | 18 +++ .../internal/db/post/model.go | 14 ++ .../internal/db/post/update_post.go | 36 +++++ .../internal/db/seeds/main.go | 90 +++++++++++++ .../internal/db/seeds/post.go | 64 +++++++++ exercise7/blogging-platform/main.go | 49 +++---- .../pkg/httputils/request/body.go | 2 +- 52 files changed, 2160 insertions(+), 28 deletions(-) create mode 100644 exercise7/blogging-platform/.dockerignore create mode 100644 exercise7/blogging-platform/.gitignore create mode 100644 exercise7/blogging-platform/Dockerfile create mode 100644 exercise7/blogging-platform/compose.yaml create mode 100644 exercise7/blogging-platform/internal/api/handler/auth/access_token.go create mode 100644 exercise7/blogging-platform/internal/api/handler/auth/login.go create mode 100644 exercise7/blogging-platform/internal/api/handler/auth/main.go create mode 100644 exercise7/blogging-platform/internal/api/handler/auth/register.go create mode 100644 exercise7/blogging-platform/internal/api/handler/main.go create mode 100644 exercise7/blogging-platform/internal/api/handler/posts/create_post.go create mode 100644 exercise7/blogging-platform/internal/api/handler/posts/delete_post.go create mode 100644 exercise7/blogging-platform/internal/api/handler/posts/find_post.go create mode 100644 exercise7/blogging-platform/internal/api/handler/posts/find_posts.go create mode 100644 exercise7/blogging-platform/internal/api/handler/posts/main.go create mode 100644 exercise7/blogging-platform/internal/api/handler/posts/update_post.go create mode 100644 exercise7/blogging-platform/internal/api/main.go create mode 100644 exercise7/blogging-platform/internal/api/middleware/authenticator.go create mode 100644 exercise7/blogging-platform/internal/api/middleware/main.go create mode 100644 exercise7/blogging-platform/internal/api/router/auth.go create mode 100644 exercise7/blogging-platform/internal/api/router/main.go create mode 100644 exercise7/blogging-platform/internal/api/router/posts.go create mode 100644 exercise7/blogging-platform/internal/auth/hash.go create mode 100644 exercise7/blogging-platform/internal/auth/main.go create mode 100644 exercise7/blogging-platform/internal/auth/tokens.go create mode 100644 exercise7/blogging-platform/internal/cli/constants.go create mode 100644 exercise7/blogging-platform/internal/cli/main.go create mode 100644 exercise7/blogging-platform/internal/cli/subcmd.go create mode 100644 exercise7/blogging-platform/internal/cli/subcmdSeed.go create mode 100644 exercise7/blogging-platform/internal/db/auth/access_token.go create mode 100644 exercise7/blogging-platform/internal/db/auth/login.go create mode 100644 exercise7/blogging-platform/internal/db/auth/main.go create mode 100644 exercise7/blogging-platform/internal/db/auth/model.go create mode 100644 exercise7/blogging-platform/internal/db/auth/register.go create mode 100644 exercise7/blogging-platform/internal/db/init.go create mode 100644 exercise7/blogging-platform/internal/db/main.go create mode 100644 exercise7/blogging-platform/internal/db/migrations/20241205151103_create_post_table.down.sql create mode 100644 exercise7/blogging-platform/internal/db/migrations/20241205151103_create_post_table.up.sql create mode 100644 exercise7/blogging-platform/internal/db/migrations/20241219144054_create_user_table.down.sql create mode 100644 exercise7/blogging-platform/internal/db/migrations/20241219144054_create_user_table.up.sql create mode 100644 exercise7/blogging-platform/internal/db/post/create_post.go create mode 100644 exercise7/blogging-platform/internal/db/post/delete_post.go create mode 100644 exercise7/blogging-platform/internal/db/post/find_post.go create mode 100644 exercise7/blogging-platform/internal/db/post/find_posts.go create mode 100644 exercise7/blogging-platform/internal/db/post/main.go create mode 100644 exercise7/blogging-platform/internal/db/post/model.go create mode 100644 exercise7/blogging-platform/internal/db/post/update_post.go create mode 100644 exercise7/blogging-platform/internal/db/seeds/main.go create mode 100644 exercise7/blogging-platform/internal/db/seeds/post.go diff --git a/exercise7/blogging-platform/.dockerignore b/exercise7/blogging-platform/.dockerignore new file mode 100644 index 00000000..9e03c484 --- /dev/null +++ b/exercise7/blogging-platform/.dockerignore @@ -0,0 +1,32 @@ +# Include any files or directories that you don't want to be copied to your +# container here (e.g., local build artifacts, temporary files, etc.). +# +# For more help, visit the .dockerignore file reference guide at +# https://docs.docker.com/go/build-context-dockerignore/ + +**/.DS_Store +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/bin +**/charts +**/docker-compose* +**/compose.y*ml +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md diff --git a/exercise7/blogging-platform/.gitignore b/exercise7/blogging-platform/.gitignore new file mode 100644 index 00000000..901a9fa8 --- /dev/null +++ b/exercise7/blogging-platform/.gitignore @@ -0,0 +1,28 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work +go.work.sum + +# env file +.env + +# postgres local volume +db-data diff --git a/exercise7/blogging-platform/Dockerfile b/exercise7/blogging-platform/Dockerfile new file mode 100644 index 00000000..ed4e193c --- /dev/null +++ b/exercise7/blogging-platform/Dockerfile @@ -0,0 +1,78 @@ +# syntax=docker/dockerfile:1 + +# Comments are provided throughout this file to help you get started. +# If you need more help, visit the Dockerfile reference guide at +# https://docs.docker.com/go/dockerfile-reference/ + +# Want to help us make this template better? Share your feedback here: https://forms.gle/ybq9Krt8jtBL3iCk7 + +################################################################################ +# Create a stage for building the application. +ARG GO_VERSION=1.23 +FROM --platform=$BUILDPLATFORM golang:${GO_VERSION} AS build +WORKDIR /src + +# Download dependencies as a separate step to take advantage of Docker's caching. +# Leverage a cache mount to /go/pkg/mod/ to speed up subsequent builds. +# Leverage bind mounts to go.sum and go.mod to avoid having to copy them into +# the container. +RUN --mount=type=cache,target=/go/pkg/mod/ \ + --mount=type=bind,source=go.sum,target=go.sum \ + --mount=type=bind,source=go.mod,target=go.mod \ + go mod download -x + +# This is the architecture you're building for, which is passed in by the builder. +# Placing it here allows the previous steps to be cached across architectures. +ARG TARGETARCH + +# Build the application. +# Leverage a cache mount to /go/pkg/mod/ to speed up subsequent builds. +# Leverage a bind mount to the current directory to avoid having to copy the +# source code into the container. +RUN --mount=type=cache,target=/go/pkg/mod/ \ + --mount=type=bind,target=. \ + CGO_ENABLED=0 GOARCH=$TARGETARCH go build -o /bin/server . + +################################################################################ +# Create a new stage for running the application that contains the minimal +# runtime dependencies for the application. This often uses a different base +# image from the build stage where the necessary files are copied from the build +# stage. +# +# The example below uses the alpine image as the foundation for running the app. +# By specifying the "latest" tag, it will also use whatever happens to be the +# most recent version of that image when you build your Dockerfile. If +# reproducability is important, consider using a versioned tag +# (e.g., alpine:3.17.2) or SHA (e.g., alpine@sha256:c41ab5c992deb4fe7e5da09f67a8804a46bd0592bfdf0b1847dde0e0889d2bff). +FROM alpine:latest AS final + +# Install any runtime dependencies that are needed to run your application. +# Leverage a cache mount to /var/cache/apk/ to speed up subsequent builds. +RUN --mount=type=cache,target=/var/cache/apk \ + apk --update add \ + ca-certificates \ + tzdata \ + && \ + update-ca-certificates + +# Create a non-privileged user that the app will run under. +# See https://docs.docker.com/go/dockerfile-user-best-practices/ +ARG UID=10001 +RUN adduser \ + --disabled-password \ + --gecos "" \ + --home "/nonexistent" \ + --shell "/sbin/nologin" \ + --no-create-home \ + --uid "${UID}" \ + appuser +USER appuser + +# Copy the executable from the "build" stage. +COPY --from=build /bin/server /bin/ + +# Expose the port that the application listens on. +EXPOSE 80 + +# What the container should run when it is started. +ENTRYPOINT [ "/bin/server" ] diff --git a/exercise7/blogging-platform/compose.yaml b/exercise7/blogging-platform/compose.yaml new file mode 100644 index 00000000..83f4063e --- /dev/null +++ b/exercise7/blogging-platform/compose.yaml @@ -0,0 +1,70 @@ +# Comments are provided throughout this file to help you get started. +# If you need more help, visit the Docker Compose reference guide at +# https://docs.docker.com/go/compose-spec-reference/ + +# Here the instructions define your application as a service called "server". +# This service is built from the Dockerfile in the current directory. +# You can add other services your application may depend on here, such as a +# database or a cache. For examples, see the Awesome Compose repository: +# https://github.com/docker/awesome-compose +services: + server: + build: + context: . + target: final + environment: + - API_PORT=80 + - DB_HOST=db + - DB_PORT=5432 + - DB_NAME=$DB_NAME + - DB_USER=$DB_USER + - DB_PASSWORD=$DB_PASSWORD + ports: + - ${API_PORT}:80 + depends_on: + db: + condition: service_healthy + + db: + image: postgres + restart: always + volumes: + - ./db-data:/var/lib/postgresql/data + environment: + - POSTGRES_DB=$DB_NAME + - POSTGRES_USER=$DB_USER + - POSTGRES_PASSWORD=$DB_PASSWORD + ports: + - ${DB_PORT}:5432 + healthcheck: + test: [ "CMD", "pg_isready" ] + interval: 10s + timeout: 5s + retries: 5 + + migrate: + image: migrate/migrate + profiles: [ "tools" ] + entrypoint: [ + "migrate", + "-path", + "./migrations", + "-database", + "postgres://$DB_USER:$DB_PASSWORD@db:5432/$DB_NAME?sslmode=disable" + ] + volumes: + - ./internal/db/migrations:/migrations + depends_on: + db: + condition: service_healthy + +volumes: + db-data: + +# The commented out section below is an example of how to define a PostgreSQL +# database that your application can use. `depends_on` tells Docker Compose to +# start the database before your application. The `db-data` volume persists the +# database data between container restarts. The `db-password` secret is used +# to set the database password. You must create `db/password.txt` and add +# a password of your choosing to it before running `docker compose up`. + diff --git a/exercise7/blogging-platform/go.mod b/exercise7/blogging-platform/go.mod index ca16e703..fea51602 100644 --- a/exercise7/blogging-platform/go.mod +++ b/exercise7/blogging-platform/go.mod @@ -1,5 +1,9 @@ -module github.com/talgat-ruby/exercises-go/exercise7/blogging-platform +module github.com/webaiz/exercise7/blogging-platform -go 1.23.3 +go 1.23 -require github.com/lib/pq v1.10.9 +require ( + github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/joho/godotenv v1.5.1 + github.com/lib/pq v1.10.9 +) diff --git a/exercise7/blogging-platform/go.sum b/exercise7/blogging-platform/go.sum index aeddeae3..642544c9 100644 --- a/exercise7/blogging-platform/go.sum +++ b/exercise7/blogging-platform/go.sum @@ -1,2 +1,4 @@ +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= diff --git a/exercise7/blogging-platform/internal/api/handler/auth/access_token.go b/exercise7/blogging-platform/internal/api/handler/auth/access_token.go new file mode 100644 index 00000000..fcb12aa9 --- /dev/null +++ b/exercise7/blogging-platform/internal/api/handler/auth/access_token.go @@ -0,0 +1,114 @@ +package auth + +import ( + "fmt" + "github.com/webaiz/exercise7/blogging-platform/internal/auth" + dbAuth "github.com/webaiz/exercise7/blogging-platform/internal/db/auth" + "github.com/webaiz/exercise7/blogging-platform/pkg/httputils/request" + "github.com/webaiz/exercise7/blogging-platform/pkg/httputils/response" + "net/http" + "os" +) + +type AccessTokenRefreshTokenRequest struct { + RefreshToken string `json:"refresh_token"` +} + +type AccessTokenRequest struct { + Data *AccessTokenRefreshTokenRequest `json:"data"` +} + +type AccessTokenResponse struct { + Data *AuthTokenPair `json:"data"` +} + +func (h *Auth) AccessToken(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := h.logger.With("method", "AccessToken") + + // request parse + requestBody := &AccessTokenRequest{} + + if err := request.JSON(w, r, requestBody); err != nil { + log.ErrorContext( + ctx, + "failed to parse request body", + "error", err, + ) + http.Error(w, "failed to parse request body", http.StatusBadRequest) + return + } + + userData, err := auth.ParseToken(requestBody.Data.RefreshToken, os.Getenv("TOKEN_SECRET")) + if err != nil { + log.ErrorContext( + ctx, + "fail authentication", + "error", err, + ) + http.Error( + w, + http.StatusText(http.StatusUnauthorized), + http.StatusUnauthorized, + ) + return + } + + // db request + dbResp, err := h.db.AccessToken(ctx, &dbAuth.AccessTokenInput{UserID: userData.ID}) + if err != nil { + log.ErrorContext( + ctx, + "failed to query from db", + "error", err, + ) + http.Error(w, "failed to query from db", http.StatusInternalServerError) + return + } + if dbResp == nil { + log.ErrorContext( + ctx, + "row is empty", + ) + http.Error(w, "row is empty", http.StatusInternalServerError) + return + } + + tokenPair, err := auth.GenerateTokenPair( + &auth.UserData{ + ID: fmt.Sprint(dbResp.ID), + Email: dbResp.Email, + }, + os.Getenv("TOKEN_SECRET"), + ) + if err != nil { + http.Error(w, fmt.Sprintf("invalid request %w", err), http.StatusBadRequest) + return + } + + // response + respBody := &AccessTokenResponse{ + Data: &AuthTokenPair{ + AccessToken: tokenPair.AccessToken, + RefreshToken: tokenPair.RefreshToken, + }, + } + if err := response.JSON( + w, + http.StatusOK, + respBody, + ); err != nil { + log.ErrorContext( + ctx, + "fail json", + "error", err, + ) + return + } + + log.InfoContext( + ctx, + "success generate access token", + ) + return +} diff --git a/exercise7/blogging-platform/internal/api/handler/auth/login.go b/exercise7/blogging-platform/internal/api/handler/auth/login.go new file mode 100644 index 00000000..8fbb73c8 --- /dev/null +++ b/exercise7/blogging-platform/internal/api/handler/auth/login.go @@ -0,0 +1,123 @@ +package auth + +import ( + "fmt" + "github.com/webaiz/exercise7/blogging-platform/internal/auth" + "github.com/webaiz/exercise7/blogging-platform/pkg/httputils/request" + "github.com/webaiz/exercise7/blogging-platform/pkg/httputils/response" + "net/http" + "os" +) + +type LoginRequest struct { + Data *AuthUser `json:"data"` +} + +type LoginResponse struct { + Data *AuthTokenPair `json:"data"` +} + +func (h *Auth) Login(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := h.logger.With("method", "Login") + + // request parse + requestBody := &LoginRequest{} + + if err := request.JSON(w, r, requestBody); err != nil { + log.ErrorContext( + ctx, + "failed to parse request body", + "error", err, + ) + http.Error(w, "failed to parse request body", http.StatusBadRequest) + return + } + + // db request + dbResp, err := h.db.Login(ctx, requestBody.Data.Email) + if err != nil { + log.ErrorContext( + ctx, + "failed to query from db", + "error", err, + ) + http.Error(w, "failed to query from db", http.StatusInternalServerError) + return + } + + if dbResp == nil { + log.ErrorContext( + ctx, + "row is empty", + "email", requestBody.Data.Email, + ) + http.Error(w, "invalid credentials", http.StatusBadRequest) + return + } + + // passwordHash and salt + isValid, err := auth.VerifyPassword( + requestBody.Data.Password, + os.Getenv("TOKEN_PEPPER"), + dbResp.PasswordHash, + dbResp.Salt, + ) + if err != nil { + log.ErrorContext( + ctx, + "verify password failed", + "error", err, + ) + http.Error(w, "verify password failed", http.StatusInternalServerError) + return + } + if !isValid { + log.ErrorContext( + ctx, + "invalid credentials", + "email", requestBody.Data.Email, + ) + http.Error(w, "invalid credentials", http.StatusBadRequest) + return + } + + tokenPair, err := auth.GenerateTokenPair( + &auth.UserData{ + ID: fmt.Sprint(dbResp.ID), + Email: dbResp.Email, + }, + os.Getenv("TOKEN_SECRET"), + ) + if err != nil { + http.Error(w, fmt.Sprintf("invalid request %w", err), http.StatusBadRequest) + return + } + + // response + respBody := &LoginResponse{ + Data: &AuthTokenPair{ + AccessToken: tokenPair.AccessToken, + RefreshToken: tokenPair.RefreshToken, + }, + } + if err := response.JSON( + w, + http.StatusOK, + respBody, + ); err != nil { + log.ErrorContext( + ctx, + "fail json", + "error", err, + ) + return + } + + log.InfoContext( + ctx, + "success login user", + "email", dbResp.Email, + ) + return +} diff --git a/exercise7/blogging-platform/internal/api/handler/auth/main.go b/exercise7/blogging-platform/internal/api/handler/auth/main.go new file mode 100644 index 00000000..c7268bc4 --- /dev/null +++ b/exercise7/blogging-platform/internal/api/handler/auth/main.go @@ -0,0 +1,28 @@ +package auth + +import ( + "github.com/webaiz/exercise7/blogging-platform/internal/db" + "log/slog" +) + +type Auth struct { + logger *slog.Logger + db *db.DB +} + +func New(logger *slog.Logger, db *db.DB) *Auth { + return &Auth{ + logger: logger, + db: db, + } +} + +type AuthUser struct { + Email string `json:"email"` + Password string `json:"password"` +} + +type AuthTokenPair struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` +} diff --git a/exercise7/blogging-platform/internal/api/handler/auth/register.go b/exercise7/blogging-platform/internal/api/handler/auth/register.go new file mode 100644 index 00000000..f133c8d3 --- /dev/null +++ b/exercise7/blogging-platform/internal/api/handler/auth/register.go @@ -0,0 +1,107 @@ +package auth + +import ( + "fmt" + "net/http" + "os" + + "github.com/webaiz/exercise7/blogging-platform/internal/auth" + dbAuth "github.com/webaiz/exercise7/blogging-platform/internal/db/auth" + "github.com/webaiz/exercise7/blogging-platform/pkg/httputils/request" + "github.com/webaiz/exercise7/blogging-platform/pkg/httputils/response" +) + +type RegisterRequest struct { + Data *AuthUser `json:"data"` +} + +type RegisterResponse struct { + Data *AuthTokenPair `json:"data"` +} + +func (h *Auth) Register(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := h.logger.With("method", "Register") + + // request parse + requestBody := &RegisterRequest{} + + if err := request.JSON(w, r, requestBody); err != nil { + log.ErrorContext( + ctx, + "failed to parse request body", + "error", err, + ) + http.Error(w, "failed to parse request body", http.StatusBadRequest) + return + } + + // passwordHash and salt + passHash, salt, err := auth.HashPassword(requestBody.Data.Password, os.Getenv("TOKEN_PEPPER")) + if err != nil { + log.ErrorContext( + ctx, + "hashing password failed", + "error", err, + ) + http.Error(w, "hashing password failed", http.StatusInternalServerError) + return + } + + // db request + userModel := &dbAuth.ModelUser{ + Email: requestBody.Data.Email, + PasswordHash: passHash, + Salt: salt, + } + dbResp, err := h.db.Register(ctx, &dbAuth.RegisterInput{userModel}) + if err != nil { + log.ErrorContext( + ctx, + "failed to insert user data", + "error", err, + ) + http.Error(w, "failed to insert user data", http.StatusInternalServerError) + return + } + + // create token pair + tokenPair, err := auth.GenerateTokenPair( + &auth.UserData{ + ID: fmt.Sprint(dbResp.ID), + Email: dbResp.Email, + }, + os.Getenv("TOKEN_SECRET"), + ) + + if err != nil { + http.Error(w, fmt.Sprintf("invalid request %w", err), http.StatusBadRequest) + return + } + + respBody := &RegisterResponse{ + Data: &AuthTokenPair{ + AccessToken: tokenPair.AccessToken, + RefreshToken: tokenPair.RefreshToken, + }, + } + if err := response.JSON( + w, + http.StatusOK, + respBody, + ); err != nil { + log.ErrorContext( + ctx, + "fail json", + "error", err, + ) + return + } + + log.InfoContext( + ctx, + "success register user", + "user email", userModel.Email, + ) + return +} diff --git a/exercise7/blogging-platform/internal/api/handler/main.go b/exercise7/blogging-platform/internal/api/handler/main.go new file mode 100644 index 00000000..b9b0e84d --- /dev/null +++ b/exercise7/blogging-platform/internal/api/handler/main.go @@ -0,0 +1,20 @@ +package handler + +import ( + "github.com/webaiz/exercise7/blogging-platform/internal/api/handler/auth" + "github.com/webaiz/exercise7/blogging-platform/internal/api/handler/posts" + "github.com/webaiz/exercise7/blogging-platform/internal/db" + "log/slog" +) + +type Handler struct { + *auth.Auth + *posts.Posts +} + +func New(logger *slog.Logger, db *db.DB) *Handler { + return &Handler{ + Auth: auth.New(logger, db), + Posts: posts.New(logger, db), + } +} diff --git a/exercise7/blogging-platform/internal/api/handler/posts/create_post.go b/exercise7/blogging-platform/internal/api/handler/posts/create_post.go new file mode 100644 index 00000000..1f3cb43f --- /dev/null +++ b/exercise7/blogging-platform/internal/api/handler/posts/create_post.go @@ -0,0 +1,96 @@ +package posts + +import ( + "fmt" + "net/http" + + "github.com/webaiz/exercise7/blogging-platform/internal/auth" + "github.com/webaiz/exercise7/blogging-platform/internal/db/post" + "github.com/webaiz/exercise7/blogging-platform/pkg/httputils/request" + "github.com/webaiz/exercise7/blogging-platform/pkg/httputils/response" +) + +type CreatePostRequest struct { + Data *post.ModelPost `json:"data"` +} + +type CreatePostResponse struct { + Data *post.ModelPost `json:"data"` +} + +func (h *Posts) CreatePost(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := h.logger.With("method", "CreatePost") + + user, ok := ctx.Value("user").(*auth.UserData) + if !ok { + log.ErrorContext( + ctx, + "failed to type cast user data", + ) + http.Error(w, "failed to parse request body", http.StatusBadRequest) + return + } + + fmt.Printf("user: %+v\n", *user) + + // request parse + requestBody := &CreatePostRequest{} + + if err := request.JSON(w, r, requestBody); err != nil { + log.ErrorContext( + ctx, + "failed to parse request body", + "error", err, + ) + http.Error(w, "failed to parse request body", http.StatusBadRequest) + return + } + + // db request + dbResp, err := h.db.CreatePost(ctx, requestBody.Data) + + if err != nil { + log.ErrorContext( + ctx, + "failed to query from db", + "error", err, + ) + http.Error(w, "failed to query from db", http.StatusInternalServerError) + return + } + + if dbResp == nil { + log.ErrorContext( + ctx, + "row is empty", + ) + http.Error(w, "row is empty", http.StatusInternalServerError) + return + } + + // response + resp := CreatePostResponse{ + Data: dbResp, + } + + if err := response.JSON( + w, + http.StatusOK, + resp, + ); err != nil { + log.ErrorContext( + ctx, + "fail json", + "error", err, + ) + return + } + + log.InfoContext( + ctx, + "success insert post", + "post id", resp.Data.ID, + ) + return +} diff --git a/exercise7/blogging-platform/internal/api/handler/posts/delete_post.go b/exercise7/blogging-platform/internal/api/handler/posts/delete_post.go new file mode 100644 index 00000000..5784fb28 --- /dev/null +++ b/exercise7/blogging-platform/internal/api/handler/posts/delete_post.go @@ -0,0 +1,57 @@ +package posts + +import ( + "net/http" + "strconv" + + "github.com/webaiz/exercise7/blogging-platform/pkg/httputils/response" +) + +func (h *Posts) DeletePost(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := h.logger.With("method", "DeletePost") + + idStr := r.PathValue("id") + + id, err := strconv.Atoi(idStr) + if err != nil { + log.ErrorContext( + ctx, + "failed to convert id to int", + "error", err, + ) + http.Error(w, "failed to convert id to int", http.StatusBadRequest) + return + } + + // db request + if err := h.db.DeletePost(ctx, int64(id)); err != nil { + log.ErrorContext( + ctx, + "failed to query from db", + "error", err, + ) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if err := response.JSON( + w, + http.StatusNoContent, + nil, + ); err != nil { + log.ErrorContext( + ctx, + "fail json", + "error", err, + ) + return + } + + log.InfoContext( + ctx, + "success delete Post", + "id", id, + ) + return +} diff --git a/exercise7/blogging-platform/internal/api/handler/posts/find_post.go b/exercise7/blogging-platform/internal/api/handler/posts/find_post.go new file mode 100644 index 00000000..17319907 --- /dev/null +++ b/exercise7/blogging-platform/internal/api/handler/posts/find_post.go @@ -0,0 +1,67 @@ +package posts + +import ( + "net/http" + "strconv" + + "github.com/webaiz/exercise7/blogging-platform/internal/db/post" + "github.com/webaiz/exercise7/blogging-platform/pkg/httputils/response" +) + +type FindPostResponse struct { + Data *post.ModelPost `json:"data"` +} + +func (h *Posts) FindPost(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := h.logger.With("method", "FindPoste") + + idStr := r.PathValue("id") + + id, err := strconv.Atoi(idStr) + if err != nil { + log.ErrorContext( + ctx, + "failed to convert id to int", + "error", err, + ) + http.Error(w, "failed to convert id to int", http.StatusBadRequest) + return + } + + dbResp, err := h.db.FindPost(ctx, int64(id)) + + if err != nil { + log.ErrorContext( + ctx, + "failed to query from db", + "error", err, + ) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + resp := FindPostResponse{ + Data: dbResp, + } + + if err := response.JSON( + w, + http.StatusOK, + resp, + ); err != nil { + log.ErrorContext( + ctx, + "fail json", + "error", err, + ) + return + } + + log.InfoContext( + ctx, + "success find post", + "post id", resp.Data.ID, + ) + return +} diff --git a/exercise7/blogging-platform/internal/api/handler/posts/find_posts.go b/exercise7/blogging-platform/internal/api/handler/posts/find_posts.go new file mode 100644 index 00000000..b249ac87 --- /dev/null +++ b/exercise7/blogging-platform/internal/api/handler/posts/find_posts.go @@ -0,0 +1,71 @@ +package posts + +import ( + "net/http" + "strconv" + + "github.com/webaiz/exercise7/blogging-platform/internal/db/post" + "github.com/webaiz/exercise7/blogging-platform/pkg/httputils/response" +) + +type FindPostsResponse struct { + Data []post.ModelPost `json:"data"` +} + +func (h *Posts) FindPosts(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + log := h.logger.With("method", "FindPosts") + + query := r.URL.Query() + offset, err := strconv.Atoi(query.Get("offset")) + if err != nil { + log.ErrorContext( + ctx, + "fail parse query offset", + "error", err, + ) + http.Error(w, "invalid query offset", http.StatusBadRequest) + return + } + limit, err := strconv.Atoi(query.Get("limit")) + if err != nil { + log.ErrorContext( + ctx, + "fail parse query limit", + "error", err, + ) + http.Error(w, "invalid query limit", http.StatusBadRequest) + return + } + + dbResp, err := h.db.FindPosts(ctx, offset, limit) + + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + + resp := FindPostsResponse{ + Data: dbResp, + } + + if err := response.JSON( + w, + http.StatusOK, + resp, + ); err != nil { + log.ErrorContext( + ctx, + "fail json", + "error", err, + ) + return + } + + log.InfoContext( + ctx, + "success find posts", + "number_of_posts", len(resp.Data), + ) + return +} diff --git a/exercise7/blogging-platform/internal/api/handler/posts/main.go b/exercise7/blogging-platform/internal/api/handler/posts/main.go new file mode 100644 index 00000000..1f478785 --- /dev/null +++ b/exercise7/blogging-platform/internal/api/handler/posts/main.go @@ -0,0 +1,19 @@ +package posts + +import ( + "log/slog" + + "github.com/webaiz/exercise7/blogging-platform/internal/db" +) + +type Posts struct { + logger *slog.Logger + db *db.DB +} + +func New(logger *slog.Logger, db *db.DB) *Posts { + return &Posts{ + logger: logger, + db: db, + } +} diff --git a/exercise7/blogging-platform/internal/api/handler/posts/update_post.go b/exercise7/blogging-platform/internal/api/handler/posts/update_post.go new file mode 100644 index 00000000..96554237 --- /dev/null +++ b/exercise7/blogging-platform/internal/api/handler/posts/update_post.go @@ -0,0 +1,76 @@ +package posts + +import ( + "net/http" + "strconv" + + "github.com/webaiz/exercise7/blogging-platform/internal/db/post" + "github.com/webaiz/exercise7/blogging-platform/pkg/httputils/request" + "github.com/webaiz/exercise7/blogging-platform/pkg/httputils/response" +) + +type UpdatePostRequest struct { + Data *post.ModelPost `json:"data"` +} + +func (h *Posts) UpdatePost(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := h.logger.With("method", "UpdatePost") + + idStr := r.PathValue("id") + + id, err := strconv.Atoi(idStr) + if err != nil { + log.ErrorContext( + ctx, + "failed to convert id to int", + "error", err, + ) + http.Error(w, "failed to convert id to int", http.StatusBadRequest) + return + } + + // request parse + requestBody := &UpdatePostRequest{} + + if err := request.JSON(w, r, requestBody); err != nil { + log.ErrorContext( + ctx, + "failed to parse request body", + "error", err, + ) + http.Error(w, "failed to parse request body", http.StatusBadRequest) + return + } + + // db request + if err := h.db.UpdatePost(ctx, int64(id), requestBody.Data); err != nil { + log.ErrorContext( + ctx, + "failed to query from db", + "error", err, + ) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if err := response.JSON( + w, + http.StatusNoContent, + nil, + ); err != nil { + log.ErrorContext( + ctx, + "fail json", + "error", err, + ) + return + } + + log.InfoContext( + ctx, + "success update post", + "id", id, + ) + return +} diff --git a/exercise7/blogging-platform/internal/api/main.go b/exercise7/blogging-platform/internal/api/main.go new file mode 100644 index 00000000..bd14ce36 --- /dev/null +++ b/exercise7/blogging-platform/internal/api/main.go @@ -0,0 +1,57 @@ +package api + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + "os" + "strconv" + + "github.com/webaiz/exercise7/blogging-platform/internal/api/handler" + "github.com/webaiz/exercise7/blogging-platform/internal/api/middleware" + "github.com/webaiz/exercise7/blogging-platform/internal/api/router" + "github.com/webaiz/exercise7/blogging-platform/internal/db" +) + +type Api struct { + logger *slog.Logger + router *router.Router +} + +func New(logger *slog.Logger, db *db.DB) *Api { + midd := middleware.New(logger) + h := handler.New(logger, db) + r := router.New(h, midd) + + return &Api{ + logger: logger, + router: r, + } +} + +func (a *Api) Start(ctx context.Context) error { + mux := a.router.Start(ctx) + + port, err := strconv.Atoi(os.Getenv("API_PORT")) + if err != nil { + return err + } + + srv := &http.Server{ + Addr: fmt.Sprintf(":%d", port), + Handler: mux, + BaseContext: func(_ net.Listener) context.Context { + return ctx + }, + } + + fmt.Printf("Starting server on :%d\n", port) + if err := srv.ListenAndServe(); err != nil && errors.Is(err, http.ErrServerClosed) { + return err + } + + return nil +} diff --git a/exercise7/blogging-platform/internal/api/middleware/authenticator.go b/exercise7/blogging-platform/internal/api/middleware/authenticator.go new file mode 100644 index 00000000..a78eb3b7 --- /dev/null +++ b/exercise7/blogging-platform/internal/api/middleware/authenticator.go @@ -0,0 +1,69 @@ +package middleware + +import ( + "context" + "net/http" + "os" + "strings" + + "github.com/webaiz/exercise7/blogging-platform/internal/auth" +) + +func (m *Middleware) Authenticator( + next http.Handler, +) http.Handler { + h := func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := m.log.With("middleware", "Authenticator") + + authorizationHeader := r.Header.Get("Authorization") + if authorizationHeader == "" { + log.ErrorContext( + ctx, + "authorization header is empty", + ) + http.Error( + w, + http.StatusText(http.StatusUnauthorized), + http.StatusUnauthorized, + ) + return + } + + if !strings.HasPrefix(authorizationHeader, "Bearer ") { + log.ErrorContext( + ctx, + "invalid authorization header", + ) + http.Error( + w, + http.StatusText(http.StatusUnauthorized), + http.StatusUnauthorized, + ) + return + } + + tokenString := authorizationHeader[len("Bearer "):] + + userData, err := auth.ParseToken(tokenString, os.Getenv("TOKEN_SECRET")) + if err != nil { + log.ErrorContext( + ctx, + "fail authentication", + "error", err, + ) + http.Error( + w, + http.StatusText(http.StatusUnauthorized), + http.StatusUnauthorized, + ) + return + } + + newCtx := context.WithValue(ctx, "user", userData) + + next.ServeHTTP(w, r.WithContext(newCtx)) + } + + return http.HandlerFunc(h) +} diff --git a/exercise7/blogging-platform/internal/api/middleware/main.go b/exercise7/blogging-platform/internal/api/middleware/main.go new file mode 100644 index 00000000..a83dc73f --- /dev/null +++ b/exercise7/blogging-platform/internal/api/middleware/main.go @@ -0,0 +1,17 @@ +package middleware + +import ( + "log/slog" +) + +type Middleware struct { + log *slog.Logger +} + +func New( + log *slog.Logger, +) *Middleware { + return &Middleware{ + log: log, + } +} diff --git a/exercise7/blogging-platform/internal/api/router/auth.go b/exercise7/blogging-platform/internal/api/router/auth.go new file mode 100644 index 00000000..a861e07b --- /dev/null +++ b/exercise7/blogging-platform/internal/api/router/auth.go @@ -0,0 +1,12 @@ +package router + +import ( + "context" +) + +func (r *Router) auth(ctx context.Context) { + r.router.HandleFunc("POST /register", r.handler.Register) + r.router.HandleFunc("POST /login", r.handler.Login) + + r.router.HandleFunc("POST /access-token", r.handler.AccessToken) +} diff --git a/exercise7/blogging-platform/internal/api/router/main.go b/exercise7/blogging-platform/internal/api/router/main.go new file mode 100644 index 00000000..5c0a2573 --- /dev/null +++ b/exercise7/blogging-platform/internal/api/router/main.go @@ -0,0 +1,32 @@ +package router + +import ( + "context" + "net/http" + + "github.com/webaiz/exercise7/blogging-platform/internal/api/handler" + "github.com/webaiz/exercise7/blogging-platform/internal/api/middleware" +) + +type Router struct { + router *http.ServeMux + handler *handler.Handler + midd *middleware.Middleware +} + +func New(handler *handler.Handler, midd *middleware.Middleware) *Router { + mux := http.NewServeMux() + + return &Router{ + router: mux, + handler: handler, + midd: midd, + } +} + +func (r *Router) Start(ctx context.Context) *http.ServeMux { + r.auth(ctx) + r.posts(ctx) + + return r.router +} diff --git a/exercise7/blogging-platform/internal/api/router/posts.go b/exercise7/blogging-platform/internal/api/router/posts.go new file mode 100644 index 00000000..e9a3a2f1 --- /dev/null +++ b/exercise7/blogging-platform/internal/api/router/posts.go @@ -0,0 +1,14 @@ +package router + +import ( + "context" + "net/http" +) + +func (r *Router) posts(ctx context.Context) { + r.router.Handle("GET /posts", http.HandlerFunc(r.handler.FindPosts)) + r.router.Handle("GET /posts/{id}", http.HandlerFunc(r.handler.FindPost)) + r.router.Handle("POST /posts", r.midd.Authenticator(http.HandlerFunc(r.handler.CreatePost))) + r.router.Handle("PUT /posts/{id}", http.HandlerFunc(r.handler.UpdatePost)) + r.router.Handle("DELETE /posts/{id}", http.HandlerFunc(r.handler.DeletePost)) +} diff --git a/exercise7/blogging-platform/internal/auth/hash.go b/exercise7/blogging-platform/internal/auth/hash.go new file mode 100644 index 00000000..016883aa --- /dev/null +++ b/exercise7/blogging-platform/internal/auth/hash.go @@ -0,0 +1,71 @@ +package auth + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha512" + "crypto/subtle" + "encoding/base64" + "fmt" +) + +const ( + // Recommended minimum lengths for security + SaltLength = 16 // 16 bytes = 128 bits + PepperLength = 32 // 32 bytes = 256 bits + HashLength = 64 // SHA-512 outputs 64 bytes + Iterations = 210000 // Recommended minimum iterations for PBKDF2 +) + +func HashPassword(password string, pepper string) (string, string, error) { + // Generate random salt + salt := make([]byte, SaltLength) + if _, err := rand.Read(salt); err != nil { + return "", "", fmt.Errorf("error generating salt: %w", err) + } + + // Hash the password + hash := hashWithSaltAndPepper([]byte(password), salt, []byte(pepper)) + + return base64.StdEncoding.EncodeToString(hash), base64.StdEncoding.EncodeToString(salt), nil +} + +func hashWithSaltAndPepper(password, salt, pepper []byte) []byte { + // Combine password and pepper + pepperedPassword := make([]byte, len(password)+len(pepper)) + copy(pepperedPassword, password) + copy(pepperedPassword[len(password):], pepper) + + // Initialize HMAC-SHA512 + hash := hmac.New(sha512.New, salt) + + // Perform multiple iterations of hashing + result := pepperedPassword + for i := 0; i < Iterations; i++ { + hash.Reset() + hash.Write(result) + result = hash.Sum(nil) + } + + return result +} + +// VerifyPassword checks if a password matches its hash +func VerifyPassword(password, pepper, hash, salt string) (bool, error) { + // Decode salt and hash from base64 + decodedSalt, err := base64.StdEncoding.DecodeString(salt) + if err != nil { + return false, fmt.Errorf("error decoding salt: %w", err) + } + + decodedHash, err := base64.StdEncoding.DecodeString(hash) + if err != nil { + return false, fmt.Errorf("error decoding hash: %w", err) + } + + // Hash the provided password with the same salt + newHash := hashWithSaltAndPepper([]byte(password), decodedSalt, []byte(pepper)) + + // Compare hashes in constant time to prevent timing attacks + return subtle.ConstantTimeCompare(newHash, decodedHash) == 1, nil +} diff --git a/exercise7/blogging-platform/internal/auth/main.go b/exercise7/blogging-platform/internal/auth/main.go new file mode 100644 index 00000000..3cf5f8ba --- /dev/null +++ b/exercise7/blogging-platform/internal/auth/main.go @@ -0,0 +1,11 @@ +package auth + +type Tokens struct { + RefreshToken string `json:"refresh_token"` + AccessToken string `json:"access_token"` +} + +type UserData struct { + ID string `json:"id"` + Email string `json:"email"` +} diff --git a/exercise7/blogging-platform/internal/auth/tokens.go b/exercise7/blogging-platform/internal/auth/tokens.go new file mode 100644 index 00000000..7276515d --- /dev/null +++ b/exercise7/blogging-platform/internal/auth/tokens.go @@ -0,0 +1,83 @@ +package auth + +import ( + "errors" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +func GenerateTokenPair(user *UserData, secret string) (*Tokens, error) { + // Create token + token := jwt.New(jwt.SigningMethodHS256) + + // Set claims + // This is the information which frontend can use + // The backend can also decode the token and get admin etc. + claims := token.Claims.(jwt.MapClaims) + claims["sub"] = user.ID + claims["email"] = user.Email + claims["exp"] = time.Now().Add(time.Minute * 15).Unix() + + // Generate encoded token and send it as response. + // The signing string should be secret (a generated UUID works too) + t, err := token.SignedString([]byte(secret)) + if err != nil { + return nil, err + } + + refreshToken := jwt.New(jwt.SigningMethodHS256) + rtClaims := refreshToken.Claims.(jwt.MapClaims) + rtClaims["sub"] = user.ID + rtClaims["exp"] = time.Now().Add(time.Hour * 24).Unix() + + rt, err := refreshToken.SignedString([]byte(secret)) + if err != nil { + return nil, err + } + + return &Tokens{ + AccessToken: t, + RefreshToken: rt, + }, nil +} + +func ParseToken(token string, secret string) (*UserData, error) { + t, err := jwt.Parse( + token, func(token *jwt.Token) (interface{}, error) { + return []byte(secret), nil + }, + ) + + switch { + case t == nil: + return nil, fmt.Errorf("invalid token") + case t.Valid: + claims, ok := t.Claims.(jwt.MapClaims) + if !ok { + return nil, fmt.Errorf("invalid token") + } + + id, err := claims.GetSubject() + if err != nil { + return nil, fmt.Errorf("invalid token") + } + email, ok := claims["email"].(string) + + return &UserData{ + ID: id, + Email: email, + }, nil + case errors.Is(err, jwt.ErrTokenMalformed): + return nil, fmt.Errorf("invalid token") + case errors.Is(err, jwt.ErrTokenSignatureInvalid): + // Invalid signature + return nil, fmt.Errorf("Invalid signature") + case errors.Is(err, jwt.ErrTokenExpired) || errors.Is(err, jwt.ErrTokenNotValidYet): + // Token is either expired or not active yet + return nil, err + default: + return nil, fmt.Errorf("invalid token") + } +} diff --git a/exercise7/blogging-platform/internal/cli/constants.go b/exercise7/blogging-platform/internal/cli/constants.go new file mode 100644 index 00000000..c180314a --- /dev/null +++ b/exercise7/blogging-platform/internal/cli/constants.go @@ -0,0 +1,3 @@ +package main + +const cmdNameSeed = "seed" diff --git a/exercise7/blogging-platform/internal/cli/main.go b/exercise7/blogging-platform/internal/cli/main.go new file mode 100644 index 00000000..230e6e54 --- /dev/null +++ b/exercise7/blogging-platform/internal/cli/main.go @@ -0,0 +1,40 @@ +package main + +import ( + "flag" + "fmt" + "os" +) + +func main() { + subcmdSeed := newSubcmdSeed() + + // Verify that a subcommand has been provided + // os.Arg[0] is the main command + // os.Arg[1] will be the subcommand + if len(os.Args) < 2 { + fmt.Printf( + "one of subcommands: %v is required\n", + []string{ + subcmdSeed.getCmdName(), + }, + ) + os.Exit(1) + } + + // Switch on the subcommand + // Parse the flags for appropriate FlagSet + // FlagSet.Parse() requires a set of arguments to parse as input + // os.Args[2:] will be all arguments starting after the subcommand at os.Args[1] + switch os.Args[1] { + case subcmdSeed.getCmdName(): + if err := subcmdSeed.parse(os.Args[2:]); err != nil { + fmt.Printf("error in cmd %s: %+v", subcmdSeed.getCmdName(), err) + subcmdSeed.printDefaults() + os.Exit(1) + } + default: + flag.PrintDefaults() + os.Exit(1) + } +} diff --git a/exercise7/blogging-platform/internal/cli/subcmd.go b/exercise7/blogging-platform/internal/cli/subcmd.go new file mode 100644 index 00000000..031c26d3 --- /dev/null +++ b/exercise7/blogging-platform/internal/cli/subcmd.go @@ -0,0 +1,7 @@ +package main + +type subcmd interface { + getCmdName() string + printDefaults() + parse([]string) error +} diff --git a/exercise7/blogging-platform/internal/cli/subcmdSeed.go b/exercise7/blogging-platform/internal/cli/subcmdSeed.go new file mode 100644 index 00000000..aa8ead57 --- /dev/null +++ b/exercise7/blogging-platform/internal/cli/subcmdSeed.go @@ -0,0 +1,51 @@ +package main + +import ( + "flag" + "fmt" + "github.com/webaiz/exercise7/blogging-platform/internal/db/seeds" +) + +type subcmdSeed struct { + cmd *flag.FlagSet +} + +func newSubcmdSeed() subcmd { + cmd := flag.NewFlagSet(cmdNameSeed, flag.ExitOnError) + + return &subcmdSeed{ + cmd, + } +} + +func (s *subcmdSeed) getCmdName() string { + return s.cmd.Name() +} + +func (s *subcmdSeed) printDefaults() { + s.cmd.PrintDefaults() +} + +func (s *subcmdSeed) parse(args []string) error { + if err := s.cmd.Parse(args); err != nil { + return err + } + + // Check which subcommand was Parsed using the FlagSet.Parsed() function. Handle each case accordingly. + // FlagSet.Parse() will evaluate to false if no flags were parsed (i.e. the user did not provide any flags) + if !s.cmd.Parsed() { + return fmt.Errorf("please provide correct arguments to %s command", s.cmd.Name()) + } + + seeder, err := seeds.New() + if err != nil { + return err + } + + err = seeder.Populate() + if err != nil { + return err + } + + return nil +} diff --git a/exercise7/blogging-platform/internal/db/auth/access_token.go b/exercise7/blogging-platform/internal/db/auth/access_token.go new file mode 100644 index 00000000..45b08676 --- /dev/null +++ b/exercise7/blogging-platform/internal/db/auth/access_token.go @@ -0,0 +1,45 @@ +package auth + +import ( + "context" + "database/sql" + "errors" +) + +type AccessTokenInput struct { + UserID string +} + +func (m *Auth) AccessToken(ctx context.Context, inp *AccessTokenInput) (*ModelUser, error) { + log := m.logger.With("method", "AccessToken") + + stmt := ` +SELECT id, email +FROM user_ +WHERE id = $1; +` + + row := m.db.QueryRowContext(ctx, stmt, inp.UserID) + if err := row.Err(); err != nil { + log.ErrorContext(ctx, "fail to query table user", "error", err) + return nil, err + } + + user := ModelUser{} + + if err := row.Scan( + &user.ID, + &user.Email, + ); err != nil { + if errors.Is(err, sql.ErrNoRows) { + log.ErrorContext(ctx, "no user found", "error", err) + return nil, nil + } + + log.ErrorContext(ctx, "fail to scan user", "error", err) + return nil, err + } + + log.InfoContext(ctx, "success select user") + return &user, nil +} diff --git a/exercise7/blogging-platform/internal/db/auth/login.go b/exercise7/blogging-platform/internal/db/auth/login.go new file mode 100644 index 00000000..dd3034b1 --- /dev/null +++ b/exercise7/blogging-platform/internal/db/auth/login.go @@ -0,0 +1,48 @@ +package auth + +import ( + "context" + "database/sql" + "errors" +) + +type LoginInput struct { + User *ModelUser +} + +func (m *Auth) Login(ctx context.Context, email string) (*ModelUser, error) { + log := m.logger.With("method", "Login") + + stmt := ` +SELECT id, email, password_hash, salt +FROM user_ +WHERE email = $1; +` + + row := m.db.QueryRowContext(ctx, stmt, email) + + if err := row.Err(); err != nil { + log.ErrorContext(ctx, "fail to query table user", "error", err) + return nil, err + } + + user := ModelUser{} + + if err := row.Scan( + &user.ID, + &user.Email, + &user.PasswordHash, + &user.Salt, + ); err != nil { + if errors.Is(err, sql.ErrNoRows) { + log.WarnContext(ctx, "no user found", "error", err) + return nil, nil + } + + log.ErrorContext(ctx, "fail to scan user", "error", err) + return nil, err + } + + log.InfoContext(ctx, "success login user") + return &user, nil +} diff --git a/exercise7/blogging-platform/internal/db/auth/main.go b/exercise7/blogging-platform/internal/db/auth/main.go new file mode 100644 index 00000000..bba67916 --- /dev/null +++ b/exercise7/blogging-platform/internal/db/auth/main.go @@ -0,0 +1,18 @@ +package auth + +import ( + "database/sql" + "log/slog" +) + +type Auth struct { + logger *slog.Logger + db *sql.DB +} + +func New(db *sql.DB, logger *slog.Logger) *Auth { + return &Auth{ + logger: logger, + db: db, + } +} diff --git a/exercise7/blogging-platform/internal/db/auth/model.go b/exercise7/blogging-platform/internal/db/auth/model.go new file mode 100644 index 00000000..3d9b81f9 --- /dev/null +++ b/exercise7/blogging-platform/internal/db/auth/model.go @@ -0,0 +1,14 @@ +package auth + +import ( + "time" +) + +type ModelUser struct { + ID int `json:"id"` + Email string `json:"email"` + PasswordHash string `json:"-"` + Salt string `json:"-"` + CreatedAt *time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at"` +} diff --git a/exercise7/blogging-platform/internal/db/auth/register.go b/exercise7/blogging-platform/internal/db/auth/register.go new file mode 100644 index 00000000..a06852b5 --- /dev/null +++ b/exercise7/blogging-platform/internal/db/auth/register.go @@ -0,0 +1,40 @@ +package auth + +import ( + "context" +) + +type RegisterInput struct { + User *ModelUser +} + +func (m *Auth) Register(ctx context.Context, inp *RegisterInput) (*ModelUser, error) { + log := m.logger.With("method", "Register") + + stmt := ` +INSERT INTO user_ (email, password_hash, salt) +VALUES ($1, $2, $3) +RETURNING id, email, password_hash, salt; +` + + row := m.db.QueryRowContext(ctx, stmt, inp.User.Email, inp.User.PasswordHash, inp.User.Salt) + if err := row.Err(); err != nil { + log.ErrorContext(ctx, "fail to query table user", "error", err) + return nil, err + } + + user := ModelUser{} + + if err := row.Scan( + &user.ID, + &user.Email, + &user.PasswordHash, + &user.Salt, + ); err != nil { + log.ErrorContext(ctx, "fail to scan user", "error", err) + return nil, err + } + + log.InfoContext(ctx, "success register user") + return &user, nil +} diff --git a/exercise7/blogging-platform/internal/db/init.go b/exercise7/blogging-platform/internal/db/init.go new file mode 100644 index 00000000..0b834ede --- /dev/null +++ b/exercise7/blogging-platform/internal/db/init.go @@ -0,0 +1,40 @@ +package db + +import ( + "context" +) + +func (db *DB) Init(ctx context.Context) error { + log := db.logger.With("method", "Init") + + stmt := ` +CREATE TABLE IF NOT EXISTS post ( + id SERIAL PRIMARY KEY, + title TEXT NOT NULL, + description TEXT NOT NULL, + posterUrl TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +) +` + + if _, err := db.pg.Exec(stmt); err != nil { + log.ErrorContext(ctx, "fail create table post", "error", err) + return err + } + + seedStmt := ` +INSERT INTO post (title, description, posterUrl) +VALUES ('Lord of the Rings', 'Lord of the Rings', 'https://www.amazon.com/Lord-Rings-Movie-Poster-24x36/dp/B07D96K2QK'), + ('Back to the future', 'Back to the future', 'https://www.amazon.com/Back-Future-Movie-Poster-Regular/dp/B001CDQF8A'), + ('I, Robot', 'I, Robot', 'https://www.cinematerial.com/posts/i-robot-i343818'); +` + + if _, err := db.pg.Exec(seedStmt); err != nil { + log.ErrorContext(ctx, "fail seed table post", "error", err) + return err + } + + log.InfoContext(ctx, "success create table post") + return nil +} diff --git a/exercise7/blogging-platform/internal/db/main.go b/exercise7/blogging-platform/internal/db/main.go new file mode 100644 index 00000000..558ada77 --- /dev/null +++ b/exercise7/blogging-platform/internal/db/main.go @@ -0,0 +1,56 @@ +package db + +import ( + "database/sql" + "fmt" + "log/slog" + "os" + "strconv" + + _ "github.com/lib/pq" + "github.com/webaiz/exercise7/blogging-platform/internal/db/auth" + "github.com/webaiz/exercise7/blogging-platform/internal/db/post" +) + +type DB struct { + logger *slog.Logger + pg *sql.DB + *post.Post + *auth.Auth +} + +func New(logger *slog.Logger) (*DB, error) { + pgsql, err := NewPgSQL() + if err != nil { + return nil, err + } + + return &DB{ + logger: logger, + pg: pgsql, + Post: post.New(pgsql, logger), + Auth: auth.New(pgsql, logger), + }, nil +} + +func NewPgSQL() (*sql.DB, error) { + host := os.Getenv("DB_HOST") + port, err := strconv.Atoi(os.Getenv("DB_PORT")) + if err != nil { + return nil, err + } + user := os.Getenv("DB_USER") + password := os.Getenv("DB_PASSWORD") + dbname := os.Getenv("DB_NAME") + + psqlInfo := fmt.Sprintf( + "host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", + host, port, user, password, dbname, + ) + db, err := sql.Open("postgres", psqlInfo) + if err != nil { + return nil, err + } + + return db, nil +} diff --git a/exercise7/blogging-platform/internal/db/migrations/20241205151103_create_post_table.down.sql b/exercise7/blogging-platform/internal/db/migrations/20241205151103_create_post_table.down.sql new file mode 100644 index 00000000..5ea4ecae --- /dev/null +++ b/exercise7/blogging-platform/internal/db/migrations/20241205151103_create_post_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS post diff --git a/exercise7/blogging-platform/internal/db/migrations/20241205151103_create_post_table.up.sql b/exercise7/blogging-platform/internal/db/migrations/20241205151103_create_post_table.up.sql new file mode 100644 index 00000000..5ced4dcc --- /dev/null +++ b/exercise7/blogging-platform/internal/db/migrations/20241205151103_create_post_table.up.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS post ( + id SERIAL PRIMARY KEY, + title TEXT NOT NULL, + description TEXT NOT NULL, + posterUrl TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +) diff --git a/exercise7/blogging-platform/internal/db/migrations/20241219144054_create_user_table.down.sql b/exercise7/blogging-platform/internal/db/migrations/20241219144054_create_user_table.down.sql new file mode 100644 index 00000000..7643471e --- /dev/null +++ b/exercise7/blogging-platform/internal/db/migrations/20241219144054_create_user_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS user_ diff --git a/exercise7/blogging-platform/internal/db/migrations/20241219144054_create_user_table.up.sql b/exercise7/blogging-platform/internal/db/migrations/20241219144054_create_user_table.up.sql new file mode 100644 index 00000000..34d14996 --- /dev/null +++ b/exercise7/blogging-platform/internal/db/migrations/20241219144054_create_user_table.up.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS user_ ( + id SERIAL PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + salt TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +) diff --git a/exercise7/blogging-platform/internal/db/post/create_post.go b/exercise7/blogging-platform/internal/db/post/create_post.go new file mode 100644 index 00000000..7e76d831 --- /dev/null +++ b/exercise7/blogging-platform/internal/db/post/create_post.go @@ -0,0 +1,45 @@ +package post + +import ( + "context" + "database/sql" + "errors" +) + +func (m *Post) CreatePost(ctx context.Context, insertData *ModelPost) (*ModelPost, error) { + log := m.logger.With("method", "CreatePost") + + stmt := ` +INSERT INTO post (title, description, posterUrl) +VALUES ($1, $2, $3) +RETURNING id, title, description, posterUrl, created_at, updated_at +` + + row := m.db.QueryRowContext(ctx, stmt, insertData.Title, insertData.Description, insertData.PosterURL) + + if err := row.Err(); err != nil { + log.ErrorContext(ctx, "fail to insert to table post", "error", err) + return nil, err + } + + post := ModelPost{} + + if err := row.Scan( + &post.ID, + &post.Title, + &post.Description, + &post.PosterURL, + &post.CreatedAt, + &post.UpdatedAt, + ); err != nil { + if errors.Is(err, sql.ErrNoRows) { + log.ErrorContext(ctx, "no values was found", "error", err) + return nil, nil + } + log.ErrorContext(ctx, "fail to scan post", "error", err) + return nil, err + } + + log.InfoContext(ctx, "success insert to table post") + return &post, nil +} diff --git a/exercise7/blogging-platform/internal/db/post/delete_post.go b/exercise7/blogging-platform/internal/db/post/delete_post.go new file mode 100644 index 00000000..3c4daac4 --- /dev/null +++ b/exercise7/blogging-platform/internal/db/post/delete_post.go @@ -0,0 +1,35 @@ +package post + +import ( + "context" + "fmt" +) + +func (m *Post) DeletePost(ctx context.Context, id int64) error { + log := m.logger.With("method", "DeletePost", "id", id) + + stmt := ` +DELETE FROM post +WHERE id = $1 +` + + res, err := m.db.ExecContext(ctx, stmt, id) + if err != nil { + log.ErrorContext(ctx, "fail to delete from the table post", "error", err) + return err + } + + num, err := res.RowsAffected() + if err != nil { + log.ErrorContext(ctx, "fail to delete from the table post", "error", err) + return err + } + + if num == 0 { + log.WarnContext(ctx, "post with id was not found", "id", id) + return fmt.Errorf("post with id was not found") + } + + log.InfoContext(ctx, "success delete from the table post") + return nil +} diff --git a/exercise7/blogging-platform/internal/db/post/find_post.go b/exercise7/blogging-platform/internal/db/post/find_post.go new file mode 100644 index 00000000..255abc67 --- /dev/null +++ b/exercise7/blogging-platform/internal/db/post/find_post.go @@ -0,0 +1,39 @@ +package post + +import ( + "context" +) + +func (m *Post) FindPost(ctx context.Context, id int64) (*ModelPost, error) { + log := m.logger.With("method", "FindPost") + + stmt := ` +SELECT id, title, description, posterUrl, created_at, updated_at +FROM post +WHERE id = $1 +` + + row := m.db.QueryRowContext(ctx, stmt, id) + + if err := row.Err(); err != nil { + log.ErrorContext(ctx, "fail to query table post", "error", err) + return nil, err + } + + post := ModelPost{} + + if err := row.Scan( + &post.ID, + &post.Title, + &post.Description, + &post.PosterURL, + &post.CreatedAt, + &post.UpdatedAt, + ); err != nil { + log.ErrorContext(ctx, "fail to scan post", "error", err) + return nil, err + } + + log.InfoContext(ctx, "success query table post") + return &post, nil +} diff --git a/exercise7/blogging-platform/internal/db/post/find_posts.go b/exercise7/blogging-platform/internal/db/post/find_posts.go new file mode 100644 index 00000000..4da4777b --- /dev/null +++ b/exercise7/blogging-platform/internal/db/post/find_posts.go @@ -0,0 +1,52 @@ +package post + +import ( + "context" +) + +func (m *Post) FindPosts(ctx context.Context, offset, limit int) ([]ModelPost, error) { + log := m.logger.With("method", "FindPosts") + + posts := make([]ModelPost, 0) + + stmt := ` +SELECT id, title, description, posterUrl, created_at, updated_at +FROM post +OFFSET $1 +LIMIT $2 +` + + rows, err := m.db.QueryContext(ctx, stmt, offset, limit) + if err != nil { + log.ErrorContext(ctx, "fail to query table post", "error", err) + return nil, err + } + + defer rows.Close() + + for rows.Next() { + post := ModelPost{} + + if err := rows.Scan( + &post.ID, + &post.Title, + &post.Description, + &post.PosterURL, + &post.CreatedAt, + &post.UpdatedAt, + ); err != nil { + log.ErrorContext(ctx, "fail to scan post", "error", err) + return nil, err + } + + posts = append(posts, post) + } + + if err := rows.Err(); err != nil { + log.ErrorContext(ctx, "fail to scan rows", "error", err) + return nil, err + } + + log.InfoContext(ctx, "success query table post") + return posts, nil +} diff --git a/exercise7/blogging-platform/internal/db/post/main.go b/exercise7/blogging-platform/internal/db/post/main.go new file mode 100644 index 00000000..5995966b --- /dev/null +++ b/exercise7/blogging-platform/internal/db/post/main.go @@ -0,0 +1,18 @@ +package post + +import ( + "database/sql" + "log/slog" +) + +type Post struct { + logger *slog.Logger + db *sql.DB +} + +func New(db *sql.DB, logger *slog.Logger) *Post { + return &Post{ + logger: logger, + db: db, + } +} diff --git a/exercise7/blogging-platform/internal/db/post/model.go b/exercise7/blogging-platform/internal/db/post/model.go new file mode 100644 index 00000000..aad23e8b --- /dev/null +++ b/exercise7/blogging-platform/internal/db/post/model.go @@ -0,0 +1,14 @@ +package post + +import ( + "time" +) + +type ModelPost struct { + ID int `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + PosterURL string `json:"poster_url"` + CreatedAt *time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at"` +} diff --git a/exercise7/blogging-platform/internal/db/post/update_post.go b/exercise7/blogging-platform/internal/db/post/update_post.go new file mode 100644 index 00000000..bc5dcbf6 --- /dev/null +++ b/exercise7/blogging-platform/internal/db/post/update_post.go @@ -0,0 +1,36 @@ +package post + +import ( + "context" + "fmt" +) + +func (m *Post) UpdatePost(ctx context.Context, id int64, insertData *ModelPost) error { + log := m.logger.With("method", "UpdatePost", "id", id) + + stmt := ` +UPDATE post +SET title = $2, description = $3, posterUrl = $4 +WHERE id = $1 +` + + res, err := m.db.ExecContext(ctx, stmt, id, insertData.Title, insertData.Description, insertData.PosterURL) + if err != nil { + log.ErrorContext(ctx, "fail to update the table post", "error", err) + return err + } + + num, err := res.RowsAffected() + if err != nil { + log.ErrorContext(ctx, "fail to update from the table post", "error", err) + return err + } + + if num == 0 { + log.WarnContext(ctx, "post with id was not found", "id", id) + return fmt.Errorf("post with id was not found") + } + + log.InfoContext(ctx, "success update the table post") + return nil +} diff --git a/exercise7/blogging-platform/internal/db/seeds/main.go b/exercise7/blogging-platform/internal/db/seeds/main.go new file mode 100644 index 00000000..30ce94df --- /dev/null +++ b/exercise7/blogging-platform/internal/db/seeds/main.go @@ -0,0 +1,90 @@ +package seeds + +import ( + "database/sql" + "log" + "log/slog" + + "github.com/joho/godotenv" + + "github.com/webaiz/exercise7/blogging-platform/internal/db" +) + +type Seed interface { + Populate() error +} + +type seeder struct { + cleanups []func() error + db *sql.DB +} + +func New() (Seed, error) { + _ = godotenv.Load() + + d, err := db.NewPgSQL() + + if err != nil { + slog.Error("database connection failed", "error", err) + return nil, err + } + + return &seeder{ + db: d, + cleanups: []func() error{}, + }, nil +} + +func (s *seeder) Populate() error { + tx, err := s.db.Begin() + if err != nil { + slog.Error("failed to start transaction", "error", err) + return err + } + + if err := s.startSeeding(tx); err != nil { + slog.Error("failed to start seeding", "error", err) + err := tx.Rollback() + slog.Error("failed to rollback", "error", err) + if err != nil { + log.Fatal(err) + } + return err + } + + err = tx.Commit() + if err != nil { + slog.Error("failed to commit", "error", err) + err := tx.Rollback() + slog.Error("failed to rollback", "error", err) + if err != nil { + log.Fatal(err) + } + return err + } + + for _, cleanup := range s.cleanups { + if err := cleanup(); err != nil { + slog.Error("failed to cleanup", "error", err) + return err + } + } + + if err := s.db.Close(); err != nil { + slog.Error("failed seeding closing db", slog.String("error", err.Error())) + return err + } + + slog.Info("seeding complete!") + + return nil +} + +func (s *seeder) startSeeding(tx *sql.Tx) error { + if err := s.post(tx); err != nil { + slog.Error("failed seeding post", slog.String("error", err.Error())) + return err + } + + return nil +} diff --git a/exercise7/blogging-platform/internal/db/seeds/post.go b/exercise7/blogging-platform/internal/db/seeds/post.go new file mode 100644 index 00000000..f666b44f --- /dev/null +++ b/exercise7/blogging-platform/internal/db/seeds/post.go @@ -0,0 +1,64 @@ +package seeds + +import ( + "database/sql" + "time" +) + +type post struct { + id int + title string + description string + posterUrl string + createdAt *time.Time + updatedAt *time.Time +} + +var posts []*post + +func (s *seeder) post(tx *sql.Tx) error { + posts = []*post{ + { + title: "Lord of the Rings", + description: "Lord of the Rings", + posterUrl: "https://www.amazon.com/Lord-Rings-Movie-Poster-24x36/dp/B07D96K2QK", + }, + { + title: "Back to the future", + description: "Back to the future", + posterUrl: "https://www.amazon.com/Back-Future-Movie-Poster-Regular/dp/B001CDQF8A", + }, + { + title: "I, Robot", + description: "I, Robot", + posterUrl: "https://www.cinematerial.com/posts/i-robot-i343818", + }, + } + + sqlQuery := ` + INSERT INTO post (title, description, posterurl) + VALUES ($1, $2, $3) + RETURNING id; + ` + sqlStmt, err := tx.Prepare(sqlQuery) + if err != nil { + return err + } + + s.cleanups = append( + s.cleanups, func() error { + return sqlStmt.Close() + }, + ) + + for _, g := range posts { + err := sqlStmt. + QueryRow(g.title, g.description, g.posterUrl). + Scan(&g.id) + if err != nil { + return err + } + } + + return nil +} diff --git a/exercise7/blogging-platform/main.go b/exercise7/blogging-platform/main.go index 1ffa1477..c4e89853 100644 --- a/exercise7/blogging-platform/main.go +++ b/exercise7/blogging-platform/main.go @@ -2,48 +2,49 @@ package main import ( "context" + "fmt" "log/slog" "os" "os/signal" - "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/internal/api" - "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/internal/db" + "github.com/joho/godotenv" + + "github.com/webaiz/exercise7/blogging-platform/internal/api" + "github.com/webaiz/exercise7/blogging-platform/internal/db" ) func main() { ctx, cancel := context.WithCancel(context.Background()) - // db - _, err := db.New() + _ = godotenv.Load() + + fmt.Println(os.Getenv("API_PORT")) + + d, err := db.New(slog.With("service", "db")) if err != nil { - slog.ErrorContext( - ctx, - "initialize service error", - "service", "db", - "error", err, - ) panic(err) } - // api - a := api.New() - if err := a.Start(ctx); err != nil { - slog.ErrorContext( - ctx, - "initialize service error", - "service", "api", - "error", err, - ) - panic(err) - } + a := api.New(slog.With("service", "api"), d) + go func(ctx context.Context, cancelFunc context.CancelFunc) { + if err := a.Start(ctx); err != nil { + slog.ErrorContext(ctx, "failed to start api", "error", err.Error()) + } + + cancelFunc() + }(ctx, cancel) - go func() { + go func(cancelFunc context.CancelFunc) { shutdown := make(chan os.Signal, 1) // Create channel to signify s signal being sent signal.Notify(shutdown, os.Interrupt) // When an interrupt is sent, notify the channel sig := <-shutdown slog.WarnContext(ctx, "signal received - shutting down...", "signal", sig) - cancel() - }() + cancelFunc() + }(cancel) + + <-ctx.Done() + + fmt.Println("shutting down gracefully") } diff --git a/exercise7/blogging-platform/pkg/httputils/request/body.go b/exercise7/blogging-platform/pkg/httputils/request/body.go index 92d639f4..09837caa 100644 --- a/exercise7/blogging-platform/pkg/httputils/request/body.go +++ b/exercise7/blogging-platform/pkg/httputils/request/body.go @@ -8,7 +8,7 @@ import ( "net/http" "strings" - "github.com/talgat-ruby/exercises-go/exercise7/blogging-platform/pkg/httputils/statusError" + "github.com/webaiz/exercise7/blogging-platform/pkg/httputils/statusError" ) func JSON(w http.ResponseWriter, r *http.Request, dst interface{}) error {