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 +} 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 +} 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 +} 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 +} 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 +} 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 +} 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 +} 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 +} 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 +} 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 +} diff --git a/exercise2/problem1/problem1.go b/exercise2/problem1/problem1.go index 4763006c..23c53890 100644 --- a/exercise2/problem1/problem1.go +++ b/exercise2/problem1/problem1.go @@ -1,4 +1,10 @@ package problem1 -func isChangeEnough() { +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/problem10/problem10.go b/exercise2/problem10/problem10.go index 7142a022..552c787c 100644 --- a/exercise2/problem10/problem10.go +++ b/exercise2/problem10/problem10.go @@ -1,3 +1,13 @@ package problem10 -func factory() {} +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/problem11/problem11.go b/exercise2/problem11/problem11.go index 33988711..7ba96af7 100644 --- a/exercise2/problem11/problem11.go +++ b/exercise2/problem11/problem11.go @@ -1,3 +1,14 @@ package problem11 -func removeDups() {} +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/problem12/problem12.go b/exercise2/problem12/problem12.go index 4c1ae327..d4f0dd45 100644 --- a/exercise2/problem12/problem12.go +++ b/exercise2/problem12/problem12.go @@ -1,3 +1,27 @@ package problem11 -func keysAndValues() {} +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/problem2/problem2.go b/exercise2/problem2/problem2.go index fdb199f0..fc8edc53 100644 --- a/exercise2/problem2/problem2.go +++ b/exercise2/problem2/problem2.go @@ -1,4 +1,36 @@ package problem2 -func capitalize() { +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/problem3/problem3.go b/exercise2/problem3/problem3.go index f183fafb..2a287eee 100644 --- a/exercise2/problem3/problem3.go +++ b/exercise2/problem3/problem3.go @@ -9,5 +9,45 @@ const ( lr dir = "lr" ) -func diagonalize() { +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/problem4/problem4.go b/exercise2/problem4/problem4.go index 1f680a4d..557a2c79 100644 --- a/exercise2/problem4/problem4.go +++ b/exercise2/problem4/problem4.go @@ -1,4 +1,14 @@ package problem4 -func mapping() { +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/problem5/problem5.go b/exercise2/problem5/problem5.go index 43fb96a4..8523ea00 100644 --- a/exercise2/problem5/problem5.go +++ b/exercise2/problem5/problem5.go @@ -1,4 +1,25 @@ package problem5 -func products() { +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/problem6/problem6.go b/exercise2/problem6/problem6.go index 89fc5bfe..98414159 100644 --- a/exercise2/problem6/problem6.go +++ b/exercise2/problem6/problem6.go @@ -1,4 +1,12 @@ package problem6 -func sumOfTwo() { +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/problem7/problem7.go b/exercise2/problem7/problem7.go index 32514209..313c99f4 100644 --- a/exercise2/problem7/problem7.go +++ b/exercise2/problem7/problem7.go @@ -1,4 +1,7 @@ package problem7 -func swap() { +func swap(a *int, b *int) { + temp := *a + *a = *b + *b = temp } diff --git a/exercise2/problem8/problem8.go b/exercise2/problem8/problem8.go index 9389d3b0..7cad8b0a 100644 --- a/exercise2/problem8/problem8.go +++ b/exercise2/problem8/problem8.go @@ -2,15 +2,13 @@ package problem8 func simplify(list []string) map[string]int { var indMap map[string]int - indMap = make(map[string]int) - load(&indMap, &list) - + load(indMap, list) return indMap } -func load(m *map[string]int, students *[]string) { - for i, name := range *students { - (*m)[name] = i +func load(m map[string]int, students []string) { + for i, name := range students { + m[name] = i } } diff --git a/exercise2/problem9/problem9.go b/exercise2/problem9/problem9.go index fc96d21a..ddab5d8f 100644 --- a/exercise2/problem9/problem9.go +++ b/exercise2/problem9/problem9.go @@ -1,3 +1,10 @@ package problem9 -func factory() {} +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/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) + } } 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" +} 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/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 { 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