Skip to content

Commit

Permalink
feat: Generalized storage interface (#37)
Browse files Browse the repository at this point in the history
  • Loading branch information
deanefrati authored Oct 8, 2024
1 parent ec71348 commit 7b584c9
Show file tree
Hide file tree
Showing 5 changed files with 176 additions and 149 deletions.
13 changes: 9 additions & 4 deletions features/todo/todoService.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,20 @@ func NewTodoService() *TodoService {
}

func (t *TodoService) ListTodos(limit int, cursor string) ([]types.Todo, string, error) {
return t.storage.ListTodos(limit, cursor)
todos := []types.Todo{}
next, err := t.storage.List(&todos, "Id", limit, cursor)

return todos, next, err
}

func (t *TodoService) GetTodo(id string) (types.Todo, error) {
return t.storage.GetTodo(id)
todo := types.Todo{}
err := t.storage.Get(&todo, "Id", id)
return todo, err
}

func (t *TodoService) DeleteTodo(id string) error {
return t.storage.DeleteTodo(id)
return t.storage.Delete(&types.Todo{}, "Id", id)
}

func (t *TodoService) CreateTodo(todoToCreate types.TodoUpdate) (types.Todo, error) {
Expand Down Expand Up @@ -62,6 +67,6 @@ func (t *TodoService) CreateTodo(todoToCreate types.TodoUpdate) (types.Todo, err
todo.Id = id.String()
todo.Summary = todoToCreate.Summary

err = t.storage.CreateTodo(todo)
err = t.storage.Create(todo)
return todo, err
}
159 changes: 83 additions & 76 deletions internal/storage/dynamodb.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"encoding/json"
"fmt"
"log/slog"
"reflect"
"strings"
"sync"

"github.com/spf13/viper"
Expand All @@ -17,7 +19,6 @@ import (
"github.com/aws/aws-sdk-go-v2/service/dynamodb"

"todo-service/internal/logger"
"todo-service/types"
)

type DynamoDBAdapter struct {
Expand All @@ -26,7 +27,6 @@ type DynamoDBAdapter struct {

var dynamoDBAdapterLock = &sync.Mutex{}
var dynamoDBAdapterInstance *DynamoDBAdapter
var todosTable = "todos"

func GetDynamoDBAdapterInstance() *DynamoDBAdapter {
if dynamoDBAdapterInstance == nil {
Expand Down Expand Up @@ -72,121 +72,128 @@ func (s *DynamoDBAdapter) Ping() error {
return err
}

func (s *DynamoDBAdapter) ListTodos(limit int, cursor string) ([]types.Todo, string, error) {
todos := []types.Todo{}
nextId := ""

input := &dynamodb.ScanInput{
TableName: aws.String(todosTable),
Limit: aws.Int32(int32(limit)),
}

id, err := base64.StdEncoding.DecodeString(cursor)
func (s *DynamoDBAdapter) Create(item any) error {
i, err := attributevalue.MarshalMapWithOptions(item, func(eo *attributevalue.EncoderOptions) { eo.TagKey = "json" })
if err != nil {
return todos, "", fmt.Errorf("failed to decode next cursor: %v", err)
return fmt.Errorf("failed to marshal inpu item into dynamodb item, %v", err)
}

if len(id) > 0 {
m := map[string]string{}
err = json.Unmarshal(id, &m)
if err != nil {
return todos, nextId, fmt.Errorf("failed to Unmarshal cursor, %v", err)
}

startKey, err := attributevalue.MarshalMap(m)
if err != nil {
return todos, nextId, fmt.Errorf("failed to marshal next cursor into dynamodb StartKey, %v", err)
}

input.ExclusiveStartKey = startKey
}

response, err := s.DB.Scan(context.TODO(), input)

if err != nil {
return todos, nextId, fmt.Errorf("failed to list todos, %v", err)
}
_, err = s.DB.PutItem(context.TODO(), &dynamodb.PutItemInput{
TableName: aws.String(s.getTableName(item)),
Item: i,
})

err = attributevalue.UnmarshalListOfMaps(response.Items, &todos)
if err != nil {
return todos, nextId, fmt.Errorf("failed to marshal scan response into todo list, %v", err)
return fmt.Errorf("failed to create item: %v", err)
}

if len(response.LastEvaluatedKey) != 0 {
m := map[string]string{}
err := attributevalue.UnmarshalMap(response.LastEvaluatedKey, &m)
if err != nil {
return todos, nextId, fmt.Errorf("failed to unmarshal LastEvaluatedKey, %v", err)
}
j, err := json.Marshal(m)
if err != nil {
return todos, nextId, fmt.Errorf("failed to encode LastEvaluatedKey into nextId cursor, %v", err)
}
nextId = base64.StdEncoding.EncodeToString([]byte(j))
}
return todos, nextId, err
return nil
}

func (s *DynamoDBAdapter) GetTodo(id string) (types.Todo, error) {
todo := types.Todo{}
key, err := attributevalue.MarshalMap(map[string]string{"id": id})
func (s *DynamoDBAdapter) Get(dest any, itemKey string, itemValue string) error {
key, err := attributevalue.MarshalMap(map[string]string{strings.ToLower(itemKey): itemValue})
if err != nil {
return todo, fmt.Errorf("failed to marshal todo id into dynamodb attribute, %v", err)
return fmt.Errorf("failed to marshal item id into dynamodb attribute, %v", err)
}

response, err := s.DB.GetItem(context.TODO(), &dynamodb.GetItemInput{
TableName: aws.String(todosTable),
TableName: aws.String(s.getTableName(dest)),
Key: key,
})

if err != nil {
return todo, fmt.Errorf("failed to get todo, %v", err)
return fmt.Errorf("failed to get item, %v", err)
}

err = attributevalue.UnmarshalMap(response.Item, &todo)
if err != nil {
return todo, fmt.Errorf("failed to unmarshal dynamodb Get result into todo, %v", err)
}
if response.Item == nil {
return ErrNotFound
} else {
err = attributevalue.UnmarshalMap(response.Item, &dest)
if err != nil {
return fmt.Errorf("failed to unmarshal dynamodb Get result into dest, %v", err)
}

if todo == (types.Todo{}) {
return todo, ErrNotFound
return nil
}

return todo, nil
}

func (s *DynamoDBAdapter) DeleteTodo(id string) error {
key, err := attributevalue.MarshalMap(map[string]string{"id": id})
func (s *DynamoDBAdapter) Delete(item any, itemKey string, itemValue string) error {
key, err := attributevalue.MarshalMap(map[string]string{strings.ToLower(itemKey): itemValue})
if err != nil {
return fmt.Errorf("failed to marshal todo id into dynamodb attribute, %v", err)
return fmt.Errorf("failed to marshal item id into dynamodb attribute, %v", err)
}

_, err = s.DB.DeleteItem(context.TODO(), &dynamodb.DeleteItemInput{
TableName: aws.String(todosTable),
TableName: aws.String(s.getTableName(item)),
Key: key,
})

if err != nil {
return fmt.Errorf("failed to delete todo, %v", err)
return fmt.Errorf("failed to delete item, %v", err)
}

return nil
}

func (s *DynamoDBAdapter) CreateTodo(todo types.Todo) error {
item, err := attributevalue.MarshalMapWithOptions(todo, func(eo *attributevalue.EncoderOptions) { eo.TagKey = "json" })
func (s *DynamoDBAdapter) List(items any, itemKey string, limit int, cursor string) (string, error) {
nextId := ""

input := &dynamodb.ScanInput{
TableName: aws.String(s.getTableName(items)),
Limit: aws.Int32(int32(limit)),
}

id, err := base64.StdEncoding.DecodeString(cursor)
if err != nil {
return fmt.Errorf("failed to marshal todo into dynamodb item, %v", err)
return "", fmt.Errorf("failed to decode next cursor: %v", err)
}

_, err = s.DB.PutItem(context.TODO(), &dynamodb.PutItemInput{
TableName: aws.String(todosTable),
Item: item,
})
if len(id) > 0 {
m := map[string]string{}
err = json.Unmarshal(id, &m)
if err != nil {
return nextId, fmt.Errorf("failed to Unmarshal cursor, %v", err)
}

startKey, err := attributevalue.MarshalMap(m)
if err != nil {
return nextId, fmt.Errorf("failed to marshal next cursor into dynamodb StartKey, %v", err)
}

input.ExclusiveStartKey = startKey
}

response, err := s.DB.Scan(context.TODO(), input)

if err != nil {
return fmt.Errorf("failed to create todo: %v", err)
return nextId, fmt.Errorf("failed to list todos, %v", err)
}

return nil
err = attributevalue.UnmarshalListOfMaps(response.Items, items)
if err != nil {
return nextId, fmt.Errorf("failed to marshal scan response into item list, %v", err)
}

if len(response.LastEvaluatedKey) != 0 {
m := map[string]string{}
err := attributevalue.UnmarshalMap(response.LastEvaluatedKey, &m)
if err != nil {
return nextId, fmt.Errorf("failed to unmarshal LastEvaluatedKey, %v", err)
}
j, err := json.Marshal(m)
if err != nil {
return nextId, fmt.Errorf("failed to encode LastEvaluatedKey into nextId cursor, %v", err)
}
nextId = base64.StdEncoding.EncodeToString([]byte(j))
}
return nextId, err
}

func (s *DynamoDBAdapter) getTableName(items any) string {
tableName := ""
tableName = reflect.TypeOf(items).String()
tableName = tableName[strings.LastIndex(tableName, ".")+1:]
tableName = strings.ToLower(tableName)
tableName += "s"
return tableName
}
84 changes: 49 additions & 35 deletions internal/storage/memory.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@ import (
"encoding/base64"
"errors"
"fmt"
"reflect"
"strings"
"sync"

"todo-service/types"
)

var memoryAdapterLock = &sync.Mutex{}

type MemoryAdapter struct {
todos []types.Todo
store map[string][]interface{}
}

var memoryAdapterInstance *MemoryAdapter
Expand All @@ -22,7 +22,7 @@ func GetMemoryAdapterInstance() *MemoryAdapter {
memoryAdapterLock.Lock()
defer memoryAdapterLock.Unlock()
if memoryAdapterInstance == nil {
memoryAdapterInstance = &MemoryAdapter{todos: []types.Todo{}}
memoryAdapterInstance = &MemoryAdapter{store: map[string][]interface{}{}}
}
}
return memoryAdapterInstance
Expand All @@ -36,50 +36,64 @@ func (m *MemoryAdapter) Ping() error {
return nil
}

func (m *MemoryAdapter) ListTodos(limit int, cursor string) ([]types.Todo, string, error) {
todos := []types.Todo{}
nextId := ""
func (m *MemoryAdapter) Create(item any) error {
itemType := reflect.TypeOf(item).String()
m.store[itemType] = append(m.store[itemType], item)
return nil
}

id, err := base64.StdEncoding.DecodeString(cursor)
if err != nil {
return todos, "", fmt.Errorf("failed to decode next cursor: %v", err)
func (m *MemoryAdapter) Get(dest any, itemKey string, itemValue string) error {
t := strings.ReplaceAll(reflect.TypeOf(dest).String(), "*", "")
for _, item := range m.store[t] {
if reflect.ValueOf(item).FieldByName(itemKey).String() == itemValue {
v := reflect.ValueOf(dest)
// Check if the value is a pointer and if it's settable
if v.Kind() == reflect.Ptr && v.Elem().CanSet() {
v.Elem().Set(reflect.ValueOf(item))
}
return nil
}
}
return ErrNotFound
}

// Get one extra item to be able to set that item's Id as the cursor for the next request
for _, v := range m.todos {
if (v.Id >= string(id)) && len(todos) < limit+1 {
todos = append(todos, v)
func (m *MemoryAdapter) Delete(item any, itemKey string, itemValue string) error {
t := strings.ReplaceAll(reflect.TypeOf(item).String(), "*", "")
for k, v := range m.store[t] {
if reflect.ValueOf(v).FieldByName(itemKey).String() == itemValue {
m.store[t] = append(m.store[t][:k], m.store[t][k+1:]...)
}
}
return nil
}

// If we have a full list, set the Id of the extra last item as the next cursor and remove it from the list of items to return
if len(todos) == limit+1 {
nextId = base64.StdEncoding.EncodeToString([]byte(todos[len(todos)-1].Id))
todos = todos[:len(todos)-1]
func (m *MemoryAdapter) List(items any, itemKey string, limit int, cursor string) (string, error) {
nextId := ""

id, err := base64.StdEncoding.DecodeString(cursor)
if err != nil {
return "", fmt.Errorf("failed to decode next cursor: %v", err)
}

return todos, nextId, nil
}
r := reflect.ValueOf(items)

func (m *MemoryAdapter) GetTodo(id string) (types.Todo, error) {
for _, v := range m.todos {
if v.Id == id {
return v, nil
// Get one extra item to be able to set that item's Id as the cursor for the next request
t := strings.ReplaceAll(r.Elem().Type().String(), "[]", "")
for _, v := range m.store[t] {
if (reflect.ValueOf(v).FieldByName(itemKey).String() >= string(id)) && r.Elem().Len() < limit+1 {
r.Elem().Set(reflect.Append(r.Elem(), reflect.ValueOf(v)))
}
}
return types.Todo{}, ErrNotFound
}

func (m *MemoryAdapter) DeleteTodo(id string) error {
for k, v := range m.todos {
if v.Id == id {
m.todos = append(m.todos[:k], m.todos[k+1:]...)
// If we have a full list, set the Id of the extra last item as the next cursor and remove it from the list of items to return
if (r.Elem().Len()) == limit+1 {
lastItem := r.Elem().Index(r.Elem().Len() - 1)
nextId = base64.StdEncoding.EncodeToString([]byte(lastItem.FieldByName(itemKey).String()))
// Check if the value is a pointer and if it's settable
if r.Kind() == reflect.Ptr && r.Elem().CanSet() {
r.Elem().Set(r.Elem().Slice(0, r.Elem().Len()-1))
}
}
return nil
}

func (m *MemoryAdapter) CreateTodo(todo types.Todo) error {
m.todos = append(m.todos, todo)
return nil
return nextId, nil
}
Loading

0 comments on commit 7b584c9

Please sign in to comment.