In this example we are going to simulate e-commerce website and use DynamoDB as database. Along with a simple table, we will be using a single GSI for finding or listing items.


Heads up, most of the files here are pretty rough so it is up to you to improve them.


Note: This is slightly different version to the previous version which uses multiple GSIs and verbose models. Pagination feature in this version is also better. There is less marshalling in DynamoDB package and the database models are cleaner. Schema is not aware of the internal attribute names of models. I suggest you refer to this version instead.


Models


- Customer
- id
- first_name
- last_name
- created_at

- Product
- id
- name
- price
- tax

- Order
- id
- total
- created_at
- customer_id

- OrderProduct
- quantity
- total
- order_id
- product_id

Access patterns


Base table


# ecommerce

_part_key _sort_key _type _gsi_1_part_key _gsi_1_sort_key
--------- --------- ----- --------------- ---------------
- id#1 id#1 customer first_name#Joe last_name#Doe:id#1
- Find a customer by customer ID.
- GetItem(_part_key = id#[customer.id], _sort_key = id#[customer.id])

- id#1 id#1 product name#* price#1.98:id#1
- Find a product by product ID.
- GetItem(_part_key = id#[product.id], _sort_key = id#[product.id])

- id#1 id#1 order customer_id#1 created_at#2024-09-26 20:03:18
- Find an order by order ID.
- GetItem(_part_key = id#[order.id], _sort_key = id#[order.id])

- order_id#1 product_id#1 rder_product product_id#1 order_id#1
- List products by order ID.
- Query(_part_key = order_id#[order.id])

Global Secondary Index


# gsi_1

_gsi_1_part_key _gsi_1_sort_key _type
--------------- --------------- -----
- first_name#Joe last_name#Doe:id#1 customer
- List customers by customer first name. Optionally order by last name (`_gsi_1_sort_key`).
- Query(_gsi_1_part_key = first_name#[customer.name])

- name#* price#1.98:id#1 product
- List products whose name starts with minimum first char or full name. Optionally order by price (`_gsi_1_sort_key`).
- Query(_gsi_1_part_key = name#[customer.name char(s)])
- Query(_gsi_1_part_key = name#[customer.name])

- customer_id#1 created_at#2024-09-26T20:03:18 order
- List orders by customer ID. Optionally order by created at (`_gsi_1_sort_key`).
- Query(_gsi_1_part_key = customer_id#[customer.id])

- product_id#1 order_id#1 order_product
- List orders by product ID. Optionally order by order id (`_gsi_1_sort_key`).
- Query(_gsi_1_part_key = product_id#[product.id])

Structure


├── main.go
├── src
│   ├── api
│   │   ├── customer.go
│   │   ├── order.go
│   │   └── product.go
│   ├── app
│   │   └── error.go
│   ├── model
│   │   ├── api
│   │   │   ├── customer.go
│   │   │   ├── order.go
│   │   │   └── product.go
│   │   └── storage
│   │   └── database
│   │   ├── customer.go
│   │   ├── item.go
│   │   ├── order.go
│   │   └── product.go
│   ├── pkg
│   │   ├── xcrypto
│   │   │   ├── aes.go
│   │   │   └── random.go
│   │   └── xtranscode
│   │   └── gob.go
│   └── storage
│   ├── dynamodb.go
│   └── page.go
└── table.json

Files


table.json


{
"TableName": "ecommerce",
"AttributeDefinitions": [
{
"AttributeName": "_part_key",
"AttributeType": "S"
},
{
"AttributeName": "_sort_key",
"AttributeType": "S"
},
{
"AttributeName": "_gsi_1_part_key",
"AttributeType": "S"
},
{
"AttributeName": "_gsi_1_sort_key",
"AttributeType": "S"
}
],
"KeySchema": [
{
"AttributeName": "_part_key",
"KeyType": "HASH"
},
{
"AttributeName": "_sort_key",
"KeyType": "RANGE"
}
],
"ProvisionedThroughput": {
"ReadCapacityUnits": 5,
"WriteCapacityUnits": 5
},
"GlobalSecondaryIndexes": [
{
"IndexName": "gsi_1",
"KeySchema": [
{
"AttributeName": "_gsi_1_part_key",
"KeyType": "HASH"
},
{
"AttributeName": "_gsi_1_sort_key",
"KeyType": "RANGE"
}
],
"Projection": {
"ProjectionType": "ALL"
},
"ProvisionedThroughput": {
"ReadCapacityUnits": 5,
"WriteCapacityUnits": 5
}
}
]
}

Makefile


run:
go run -race ecommerce/main.go

db-del:
aws --profile localstack --endpoint-url http://localhost:4566 dynamodb delete-table --table-name ecommerce > /dev/null

db-crt:
aws --profile localstack --endpoint-url http://localhost:4566 dynamodb create-table --cli-input-json file:///Users/myself/dev/golang/mix/dynamodb/ecommerce/table.json > /dev/null

db-export:
aws --profile localstack --endpoint-url http://localhost:4566 dynamodb scan --table-name ecommerce --output json > /Users/myself/dev/golang/mix/dynamodb/ecommerce/table-fixtures.json

main.go


package main

import (
"context"
"log"
"net/http"

"dynamodb/ecommerce/src/api"
"dynamodb/ecommerce/src/pkg/xcrypto"
"dynamodb/ecommerce/src/pkg/xtranscode"
"dynamodb/ecommerce/src/storage"

"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/dynamodb"
)

func main() {
// random := xcrypto.Random{
// Size: xcrypto.SecretAES128,
// }
// key, err := random.Generate()
// if err != nil {
// log.Fatalln(err)
// }
// fmt.Printf("KEY: %x\n", key)
key := []byte(`c981a919d245a499c199e497a1dd599c`)

cfg, err := config.LoadDefaultConfig(context.Background())
if err != nil {
log.Fatalln(err)
}

dyb := dynamodb.NewFromConfig(cfg, func(o *dynamodb.Options) {
o.BaseEndpoint = aws.String("http://localhost:4566")
})

str := storage.DynamoDB{
Client: dyb,
Pager: &storage.Page{
Transcoder: xtranscode.GOB{},
Crypter: xcrypto.AES{
Key: key,
},
},
Table: "ecommerce",
GlobalIndex1: "gsi_1",
}

customer := api.Customer{Storage: str}
product := api.Product{Storage: str}
order := api.Order{Storage: str}

rtr := http.NewServeMux()
rtr.HandleFunc("POST /api/v1/customers", customer.Create)
rtr.HandleFunc("GET /api/v1/customers/{customer_id}", customer.FindByID)
rtr.HandleFunc("GET /api/v1/customers", customer.ListCustomersByFirstName)

rtr.HandleFunc("POST /api/v1/products", product.Create)
rtr.HandleFunc("GET /api/v1/products/{product_id}", product.FindByID)
rtr.HandleFunc("GET /api/v1/products", product.ListProductsByNamePrefix)

rtr.HandleFunc("POST /api/v1/orders", order.Create)
rtr.HandleFunc("GET /api/v1/orders/{order_id}", order.FindByID)
rtr.HandleFunc("GET /api/v1/orders/{order_id}/products", order.ListProductsByOrderID)
rtr.HandleFunc("GET /api/v1/orders/{customer_id}/customer", order.ListOrdersByCustomerID)
rtr.HandleFunc("GET /api/v1/orders/{product_id}/product", order.ListOrdersByProductID)

http.ListenAndServe(":1234", rtr)
}

app/error.go


package app

import (
"errors"
)

var (
ErrInternal = errors.New("internal")
ErrResourceNotFound = errors.New("resource not found")
)

api/customer.go


package api

import (
"encoding/json"
"net/http"
"strconv"
"time"

"dynamodb/ecommerce/src/model/api"
"dynamodb/ecommerce/src/model/storage/database"
"dynamodb/ecommerce/src/storage"

"github.com/google/uuid"
)

type Customer struct {
Storage storage.DynamoDB
}

func (c Customer) Create(w http.ResponseWriter, r *http.Request) {
var req api.CreateCustomer
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(err.Error()))
return
}

model := database.Customer{
ID: uuid.NewString(),
FirstName: req.FirstName,
LastName: req.LastName,
CreatedAt: time.Now(),
}
model.Build()

if err := c.Storage.CreateCustomer(r.Context(), model); err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return
}

w.WriteHeader(http.StatusOK)
w.Write([]byte(model.ID))
}

func (c Customer) FindByID(w http.ResponseWriter, r *http.Request) {
res, err := c.Storage.FindCustomerByID(r.Context(), r.PathValue("customer_id"))
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return
}

w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
bdy, _ := json.Marshal(res)
w.Write(bdy)
}

func (c Customer) ListCustomersByFirstName(w http.ResponseWriter, r *http.Request) {
fname := r.URL.Query().Get("first_name")
page := r.URL.Query().Get("page")
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
asc, _ := strconv.ParseBool(r.URL.Query().Get("asc"))

res, err := c.Storage.ListCustomersByFirstName(r.Context(), fname, page, limit, asc)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return
}

w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
bdy, _ := json.Marshal(res)
w.Write(bdy)
}

api/order.go


package api

import (
"encoding/json"
"net/http"
"strconv"
"time"

"dynamodb/ecommerce/src/model/api"
"dynamodb/ecommerce/src/model/storage/database"
"dynamodb/ecommerce/src/storage"

"github.com/google/uuid"
)

type Order struct {
Storage storage.DynamoDB
}

func (o Order) Create(w http.ResponseWriter, r *http.Request) {
var req api.CreateOrder
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(err.Error()))
return
}

order := database.Order{
ID: uuid.NewString(),
Total: req.Total,
CreatedAt: time.Now(),
CustomerID: req.CustomerID,
}
order.Build()

products := make([]*database.OrderProduct, 0, len(req.Products))
for _, v := range req.Products {
product := database.OrderProduct{
Quantity: v.Quantity,
Total: v.Total,
OrderID: order.ID,
ProductID: v.ID,
}
product.Build()

products = append(products, &product)
}
order.Products = products

if err := o.Storage.CreateOrder(r.Context(), order); err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return
}

w.WriteHeader(http.StatusOK)
w.Write([]byte(order.ID))
}

func (o Order) FindByID(w http.ResponseWriter, r *http.Request) {
res, err := o.Storage.FindOrderByID(r.Context(), r.PathValue("order_id"))
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return
}

w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
bdy, _ := json.Marshal(res)
w.Write(bdy)
}

func (o Order) ListProductsByOrderID(w http.ResponseWriter, r *http.Request) {
orderID := r.PathValue("order_id")
page := r.URL.Query().Get("page")
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
asc, _ := strconv.ParseBool(r.URL.Query().Get("asc"))

res, err := o.Storage.ListProductsByOrderID(r.Context(), orderID, page, limit, asc)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return
}

w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
bdy, _ := json.Marshal(res)
w.Write(bdy)
}

func (o Order) ListOrdersByCustomerID(w http.ResponseWriter, r *http.Request) {
cid := r.PathValue("customer_id")
page := r.URL.Query().Get("page")
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
asc, _ := strconv.ParseBool(r.URL.Query().Get("asc"))

res, err := o.Storage.ListOrdersByCustomerID(r.Context(), cid, page, limit, asc)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return
}

w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
bdy, _ := json.Marshal(res)
w.Write(bdy)
}

func (o Order) ListOrdersByProductID(w http.ResponseWriter, r *http.Request) {
pid := r.PathValue("product_id")
page := r.URL.Query().Get("page")
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
asc, _ := strconv.ParseBool(r.URL.Query().Get("asc"))

res, err := o.Storage.ListOrdersByProductID(r.Context(), pid, page, limit, asc)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return
}

w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
bdy, _ := json.Marshal(res)
w.Write(bdy)
}

api/product.go


package api

import (
"encoding/json"
"net/http"
"strconv"
"time"

"dynamodb/ecommerce/src/model/api"
"dynamodb/ecommerce/src/model/storage/database"
"dynamodb/ecommerce/src/storage"

"github.com/google/uuid"
)

type Product struct {
Storage storage.DynamoDB
}

func (p Product) Create(w http.ResponseWriter, r *http.Request) {
var req api.CreateProduct
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(err.Error()))
return
}

model := database.Product{
ID: uuid.NewString(),
Name: req.Name,
Price: req.Price,
Tax: req.Tax,
CreatedAt: time.Now(),
}
model.Build()

if err := p.Storage.CreateProduct(r.Context(), model); err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return
}

w.WriteHeader(http.StatusOK)
w.Write([]byte(model.ID))
}

func (p Product) FindByID(w http.ResponseWriter, r *http.Request) {
res, err := p.Storage.FindProductByID(r.Context(), r.PathValue("product_id"))
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return
}

w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
bdy, _ := json.Marshal(res)
w.Write(bdy)
}

func (p Product) ListProductsByNamePrefix(w http.ResponseWriter, r *http.Request) {
prefix := r.URL.Query().Get("prefix")
page := r.URL.Query().Get("page")
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
asc, _ := strconv.ParseBool(r.URL.Query().Get("asc"))

res, err := p.Storage.ListProductsByNamePrefix(r.Context(), prefix, page, limit, asc)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return
}

w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
bdy, _ := json.Marshal(res)
w.Write(bdy)
}

model/customer.go


package api

type CreateCustomer struct {
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
}

model/order.go


package api

type CreateOrderProduct struct {
ID string `json:"id"`
Quantity int `json:"quantity"`
Total float64 `json:"total"`
}

type CreateOrder struct {
Total float64 `json:"total"`
CustomerID string `json:"customer_id"`
Products []CreateOrderProduct `json:"products"`
}

model/product.go


package api

type CreateProduct struct {
Name string `json:"name"`
Price float64 `json:"price"`
Tax float64 `json:"tax"`
}

database/customer.go


package database

import "time"

type Customer struct {
Item

ID string `dynamodbav:"id"`
FirstName string `dynamodbav:"first_name"`
LastName string `dynamodbav:"last_name"`
CreatedAt time.Time `dynamodbav:"created_at"`
}

func (c *Customer) Build() {
c.Item = Item{
TablePartKey: "id#" + c.ID,
TableSortKey: "id#" + c.ID,
GSI1PartKey: "first_name#" + c.FirstName,
GSI1SortKey: "last_name#" + c.LastName + ":id#" + c.ID,
Type: "customer",
}
}

database/item.go


package database

type Item struct {
TablePartKey string `dynamodbav:"_part_key"`
TableSortKey string `dynamodbav:"_sort_key"`
GSI1PartKey string `dynamodbav:"_gsi_1_part_key,omitempty"`
GSI1SortKey string `dynamodbav:"_gsi_1_sort_key,omitempty"`
Type string `dynamodbav:"_type"`
}

func (Item) AttrTablePartKey() string {
return "_part_key"
}

func (Item) AttrTableSortKey() string {
return "_sort_key"
}

func (Item) AttrGSI1PartKey() string {
return "_gsi_1_part_key"
}

func (Item) AttrGSI1SortKey() string {
return "_gsi_1_sort_key"
}

database/order.go


package database

import "time"

type Order struct {
Item

ID string `dynamodbav:"id"`
Total float64 `dynamodbav:"total"`
CreatedAt time.Time `dynamodbav:"created_at"`
CustomerID string `dynamodbav:"customer_id"`
Products []*OrderProduct `dynamodbav:"-"`
}

func (o *Order) Build() {
o.Item = Item{
TablePartKey: "id#" + o.ID,
TableSortKey: "id#" + o.ID,
GSI1PartKey: "customer_id#" + o.CustomerID,
GSI1SortKey: "created_at#" + o.CreatedAt.Format(time.DateTime),
Type: "order",
}
}

type OrderProduct struct {
Item

Quantity int `dynamodbav:"quantity"`
Total float64 `dynamodbav:"total"`
OrderID string `dynamodbav:"order_id"`
ProductID string `dynamodbav:"product_id"`
}

func (o *OrderProduct) Build() {
o.Item = Item{
TablePartKey: "order_id#" + o.OrderID,
TableSortKey: "product_id#" + o.ProductID,
GSI1PartKey: "product_id#" + o.ProductID,
GSI1SortKey: "order_id#" + o.OrderID,
Type: "order_product",
}
}

database/product.go


package database

import (
"fmt"
"time"
)

type Product struct {
Item

ID string `dynamodbav:"id"`
Name string `dynamodbav:"name"`
Price float64 `dynamodbav:"price"`
Tax float64 `dynamodbav:"tax"`
CreatedAt time.Time `dynamodbav:"created_at"`
}

func (p *Product) Build() {
prefix := p.Name
if len(prefix) > 1 {
prefix = prefix[:1]
}

p.Item = Item{
TablePartKey: "id#" + p.ID,
TableSortKey: "id#" + p.ID,
GSI1PartKey: "name#" + prefix,
GSI1SortKey: fmt.Sprintf("price#%f:id#%s", p.Price, p.ID),
Type: "product",
}
}

dynamodb.go


package storage

import (
"context"
"fmt"

"dynamodb/ecommerce/src/app"
"dynamodb/ecommerce/src/model/storage/database"

"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue"
"github.com/aws/aws-sdk-go-v2/service/dynamodb"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"github.com/pkg/errors"
)

type pager interface {
LastEvaluatedKey(val map[string]types.AttributeValue) (string, error)
ExclusiveStartKey(val string) (map[string]types.AttributeValue, error)
}

type DynamoDB struct {
Client *dynamodb.Client
Pager pager
Table string
GlobalIndex1 string
}

// CREATE ----------------------------------------------------------------------

func (d DynamoDB) CreateCustomer(ctx context.Context, model database.Customer) error {
item, err := attributevalue.MarshalMap(model)
if err != nil {
return errors.Wrap(app.ErrInternal, fmt.Sprintf("marshal map: %s", err.Error()))
}

_, err = d.Client.PutItem(ctx, &dynamodb.PutItemInput{
TableName: aws.String(d.Table),
Item: item,
})
if err != nil {
return errors.Wrap(app.ErrInternal, fmt.Sprintf("put item: %s", err.Error()))
}

return nil
}

func (d DynamoDB) CreateProduct(ctx context.Context, model database.Product) error {
item, err := attributevalue.MarshalMap(model)
if err != nil {
return errors.Wrap(app.ErrInternal, fmt.Sprintf("marshal map: %s", err.Error()))
}

_, err = d.Client.PutItem(ctx, &dynamodb.PutItemInput{
TableName: aws.String(d.Table),
Item: item,
})
if err != nil {
return errors.Wrap(app.ErrInternal, fmt.Sprintf("put item: %s", err.Error()))
}

return nil
}

func (d DynamoDB) CreateOrder(ctx context.Context, model database.Order) error {
items := make([]types.TransactWriteItem, 0, len(model.Products)+1)

for _, product := range model.Products {
item, err := attributevalue.MarshalMap(product)
if err != nil {
return errors.Wrap(app.ErrInternal, fmt.Sprintf("marshal map: %s", err.Error()))
}

items = append(items, types.TransactWriteItem{
Put: &types.Put{
TableName: aws.String(d.Table),
Item: item,
},
})
}

item, err := attributevalue.MarshalMap(model)
if err != nil {
return errors.Wrap(app.ErrInternal, fmt.Sprintf("marshal map: %s", err.Error()))
}

items = append(items, types.TransactWriteItem{
Put: &types.Put{
TableName: aws.String(d.Table),
Item: item,
},
})

_, err = d.Client.TransactWriteItems(ctx, &dynamodb.TransactWriteItemsInput{
TransactItems: items,
})
if err != nil {
return errors.Wrap(app.ErrInternal, fmt.Sprintf("put item: %s", err.Error()))
}

return nil
}

// FIND ------------------------------------------------------------------------

func (d DynamoDB) FindCustomerByID(ctx context.Context, id string) (database.Customer, error) {
model := database.Customer{
ID: id,
}
model.Build()

res, err := d.Client.GetItem(ctx, &dynamodb.GetItemInput{
TableName: aws.String(d.Table),
Key: map[string]types.AttributeValue{
model.Item.AttrTablePartKey(): &types.AttributeValueMemberS{Value: model.Item.TablePartKey},
model.Item.AttrTableSortKey(): &types.AttributeValueMemberS{Value: model.Item.TableSortKey},
},
})
if err != nil {
return database.Customer{}, errors.Wrap(app.ErrInternal, fmt.Sprintf("get item: %s", err.Error()))
}

if res.Item == nil {
return database.Customer{}, app.ErrResourceNotFound
}

if err := attributevalue.UnmarshalMap(res.Item, &model); err != nil {
return database.Customer{}, errors.Wrap(app.ErrInternal, fmt.Sprintf("unmarshal map: %s", err.Error()))
}

return model, nil
}

func (d DynamoDB) FindProductByID(ctx context.Context, id string) (database.Product, error) {
model := database.Product{
ID: id,
}
model.Build()

res, err := d.Client.GetItem(ctx, &dynamodb.GetItemInput{
TableName: aws.String(d.Table),
Key: map[string]types.AttributeValue{
model.Item.AttrTablePartKey(): &types.AttributeValueMemberS{Value: model.Item.TablePartKey},
model.Item.AttrTableSortKey(): &types.AttributeValueMemberS{Value: model.Item.TableSortKey},
},
})
if err != nil {
return database.Product{}, errors.Wrap(app.ErrInternal, fmt.Sprintf("get item: %s", err.Error()))
}

if res.Item == nil {
return database.Product{}, app.ErrResourceNotFound
}

if err := attributevalue.UnmarshalMap(res.Item, &model); err != nil {
return database.Product{}, errors.Wrap(app.ErrInternal, fmt.Sprintf("unmarshal map: %s", err.Error()))
}

return model, nil
}

func (d DynamoDB) FindOrderByID(ctx context.Context, id string) (database.Order, error) {
model := database.Order{
ID: id,
}
model.Build()

res, err := d.Client.GetItem(ctx, &dynamodb.GetItemInput{
TableName: aws.String(d.Table),
Key: map[string]types.AttributeValue{
model.Item.AttrTablePartKey(): &types.AttributeValueMemberS{Value: model.Item.TablePartKey},
model.Item.AttrTableSortKey(): &types.AttributeValueMemberS{Value: model.Item.TableSortKey},
},
})
if err != nil {
return database.Order{}, errors.Wrap(app.ErrInternal, fmt.Sprintf("get item: %s", err.Error()))
}

if res.Item == nil {
return database.Order{}, app.ErrResourceNotFound
}

if err := attributevalue.UnmarshalMap(res.Item, &model); err != nil {
return database.Order{}, errors.Wrap(app.ErrInternal, fmt.Sprintf("unmarshal map: %s", err.Error()))
}

return model, nil
}

// LIST ------------------------------------------------------------------------

// The page should be enc and dec while using here otherwise passing a similar plain string would still yield a result :(
func (d DynamoDB) ListProductsByOrderID(ctx context.Context, id, page string, limit int, asc bool) ([]*database.Product, error) {
model := database.OrderProduct{
OrderID: id,
}
model.Build()

input := dynamodb.QueryInput{
TableName: aws.String(d.Table),
KeyConditionExpression: aws.String("#" + model.Item.AttrTablePartKey() + " = :pk"),
ExpressionAttributeNames: map[string]string{
"#" + model.Item.AttrTablePartKey(): model.Item.AttrTablePartKey(),
},
ExpressionAttributeValues: map[string]types.AttributeValue{
":pk": &types.AttributeValueMemberS{Value: model.Item.TablePartKey},
},
Limit: aws.Int32(int32(limit) + 1), // Workaround to say there is more pages to paginate
ScanIndexForward: aws.Bool(asc), // true == ASC
}

// Paginate by starting from where it was left off in the previous call.
if page != "" {
esk, err := d.Pager.ExclusiveStartKey(page)
if err != nil {
return nil, errors.Wrap(app.ErrInternal, fmt.Sprintf("exclusive start key: %s", err.Error()))
}
input.ExclusiveStartKey = esk
}

res, err := d.Client.Query(ctx, &input)
if err != nil {
return nil, errors.Wrap(app.ErrInternal, fmt.Sprintf("query: %s", err.Error()))
}

var models []*database.Product

if err := attributevalue.UnmarshalListOfMaps(res.Items, &models); err != nil {
return nil, errors.Wrap(app.ErrInternal, fmt.Sprintf("unmarshal list of maps: %s", err.Error()))
}

// There is no more pages to paginate.
total := len(models)
if total != limit+1 {
return models, nil
}

// There is more pages to paginate but take out the last item from the list
// before proceeding.
models = models[:total-1]

total = len(models)
if total == 0 {
return models, nil
}

// Get the last item which represents the current cursor location which where
// the next iteration will start from for the next call.
last := models[len(models)-1].Item

// Manually simulate building the `LastEvaluatedKey`.
lek := map[string]types.AttributeValue{
model.Item.AttrTablePartKey(): &types.AttributeValueMemberS{Value: last.TablePartKey},
model.Item.AttrTableSortKey(): &types.AttributeValueMemberS{Value: last.TableSortKey},
}

page, err = d.Pager.LastEvaluatedKey(lek)
if err != nil {
return nil, errors.Wrap(app.ErrInternal, fmt.Sprintf("last evaluated key: %s", err.Error()))
}
fmt.Println(page)

return models, nil
}

// The page should be enc and dec while using here otherwise passing a similar plain string would still yield a result :(
func (d DynamoDB) ListCustomersByFirstName(ctx context.Context, fname, page string, limit int, asc bool) ([]*database.Customer, error) {
model := database.Customer{
FirstName: fname,
}
model.Build()

input := dynamodb.QueryInput{
TableName: aws.String(d.Table),
IndexName: aws.String(d.GlobalIndex1),
KeyConditionExpression: aws.String("#" + model.Item.AttrGSI1PartKey() + " = :gsi_pk"),
ExpressionAttributeNames: map[string]string{
"#" + model.Item.AttrGSI1PartKey(): model.Item.AttrGSI1PartKey(),
},
ExpressionAttributeValues: map[string]types.AttributeValue{
":gsi_pk": &types.AttributeValueMemberS{Value: model.Item.GSI1PartKey},
},
Limit: aws.Int32(int32(limit) + 1), // Workaround to say there is more pages to paginate
ScanIndexForward: aws.Bool(asc), // true == ASC
}

// Paginate by starting from where it was left off in the previous call.
if page != "" {
esk, err := d.Pager.ExclusiveStartKey(page)
if err != nil {
return nil, errors.Wrap(app.ErrInternal, fmt.Sprintf("exclusive start key: %s", err.Error()))
}
input.ExclusiveStartKey = esk
}

res, err := d.Client.Query(ctx, &input)
if err != nil {
return nil, errors.Wrap(app.ErrInternal, fmt.Sprintf("query: %s", err.Error()))
}

var models []*database.Customer

if err := attributevalue.UnmarshalListOfMaps(res.Items, &models); err != nil {
return nil, errors.Wrap(app.ErrInternal, fmt.Sprintf("unmarshal list of maps: %s", err.Error()))
}

// There is no more pages to paginate.
total := len(models)
if total != limit+1 {
return models, nil
}

// There is more pages to paginate but take out the last item from the list
// before proceeding.
models = models[:total-1]

total = len(models)
if total == 0 {
return models, nil
}

// Get the last item which represents the current cursor location which where
// the next iteration will start from for the next call.
last := models[len(models)-1].Item

// Manually simulate building the `LastEvaluatedKey`.
lek := map[string]types.AttributeValue{
model.Item.AttrGSI1PartKey(): &types.AttributeValueMemberS{Value: last.GSI1PartKey},
model.Item.AttrGSI1SortKey(): &types.AttributeValueMemberS{Value: last.GSI1SortKey},
model.Item.AttrTablePartKey(): &types.AttributeValueMemberS{Value: last.TablePartKey},
model.Item.AttrTableSortKey(): &types.AttributeValueMemberS{Value: last.TableSortKey},
}

page, err = d.Pager.LastEvaluatedKey(lek)
if err != nil {
return nil, errors.Wrap(app.ErrInternal, fmt.Sprintf("last evaluated key: %s", err.Error()))
}
fmt.Println(page)

return models, nil
}

// The page should be enc and dec while using here otherwise passing a similar plain string would still yield a result :(
func (d DynamoDB) ListProductsByNamePrefix(ctx context.Context, prefix, page string, limit int, asc bool) ([]*database.Product, error) {
model := database.Product{
Name: prefix,
}
model.Build()

input := dynamodb.QueryInput{
TableName: aws.String(d.Table),
IndexName: aws.String(d.GlobalIndex1),
KeyConditionExpression: aws.String("#" + model.Item.AttrGSI1PartKey() + " = :gsi_pk"),
ExpressionAttributeNames: map[string]string{
"#" + model.Item.AttrGSI1PartKey(): model.Item.AttrGSI1PartKey(),
},
ExpressionAttributeValues: map[string]types.AttributeValue{
":gsi_pk": &types.AttributeValueMemberS{Value: model.Item.GSI1PartKey},
},
Limit: aws.Int32(int32(limit) + 1), // Workaround to say there is more pages to paginate
ScanIndexForward: aws.Bool(asc), // true == ASC
}

// Paginate by starting from where it was left off in the previous call.
if page != "" {
esk, err := d.Pager.ExclusiveStartKey(page)
if err != nil {
return nil, errors.Wrap(app.ErrInternal, fmt.Sprintf("exclusive start key: %s", err.Error()))
}
input.ExclusiveStartKey = esk
}

res, err := d.Client.Query(ctx, &input)
if err != nil {
return nil, errors.Wrap(app.ErrInternal, fmt.Sprintf("query: %s", err.Error()))
}

var models []*database.Product

if err := attributevalue.UnmarshalListOfMaps(res.Items, &models); err != nil {
return nil, errors.Wrap(app.ErrInternal, fmt.Sprintf("unmarshal list of maps: %s", err.Error()))
}

// There is no more pages to paginate.
total := len(models)
if total != limit+1 {
return models, nil
}

// There is more pages to paginate but take out the last item from the list
// before proceeding.
models = models[:total-1]

total = len(models)
if total == 0 {
return models, nil
}

// Get the last item which represents the current cursor location which where
// the next iteration will start from for the next call.
last := models[len(models)-1].Item

// Manually simulate building the `LastEvaluatedKey`.
lek := map[string]types.AttributeValue{
model.Item.AttrGSI1PartKey(): &types.AttributeValueMemberS{Value: last.GSI1PartKey},
model.Item.AttrGSI1SortKey(): &types.AttributeValueMemberS{Value: last.GSI1SortKey},
model.Item.AttrTablePartKey(): &types.AttributeValueMemberS{Value: last.TablePartKey},
model.Item.AttrTableSortKey(): &types.AttributeValueMemberS{Value: last.TableSortKey},
}

page, err = d.Pager.LastEvaluatedKey(lek)
if err != nil {
return nil, errors.Wrap(app.ErrInternal, fmt.Sprintf("last evaluated key: %s", err.Error()))
}
fmt.Println(page)

return models, nil
}

// The page should be enc and dec while using here otherwise passing a similar plain string would still yield a result :(
func (d DynamoDB) ListOrdersByCustomerID(ctx context.Context, cid, page string, limit int, asc bool) ([]*database.Order, error) {
model := database.Order{
CustomerID: cid,
}
model.Build()

input := dynamodb.QueryInput{
TableName: aws.String(d.Table),
IndexName: aws.String(d.GlobalIndex1),
KeyConditionExpression: aws.String("#" + model.Item.AttrGSI1PartKey() + " = :gsi_pk"),
ExpressionAttributeNames: map[string]string{
"#" + model.Item.AttrGSI1PartKey(): model.Item.AttrGSI1PartKey(),
},
ExpressionAttributeValues: map[string]types.AttributeValue{
":gsi_pk": &types.AttributeValueMemberS{Value: model.Item.GSI1PartKey},
},
Limit: aws.Int32(int32(limit) + 1), // Workaround to say there is more pages to paginate
ScanIndexForward: aws.Bool(asc), // true == ASC
}

// Paginate by starting from where it was left off in the previous call.
if page != "" {
esk, err := d.Pager.ExclusiveStartKey(page)
if err != nil {
return nil, errors.Wrap(app.ErrInternal, fmt.Sprintf("exclusive start key: %s", err.Error()))
}
input.ExclusiveStartKey = esk
}

res, err := d.Client.Query(ctx, &input)
if err != nil {
return nil, errors.Wrap(app.ErrInternal, fmt.Sprintf("query: %s", err.Error()))
}

var models []*database.Order

if err := attributevalue.UnmarshalListOfMaps(res.Items, &models); err != nil {
return nil, errors.Wrap(app.ErrInternal, fmt.Sprintf("unmarshal list of maps: %s", err.Error()))
}

// There is no more pages to paginate.
total := len(models)
if total != limit+1 {
return models, nil
}

// There is more pages to paginate but take out the last item from the list
// before proceeding.
models = models[:total-1]

total = len(models)
if total == 0 {
return models, nil
}

// Get the last item which represents the current cursor location which where
// the next iteration will start from for the next call.
last := models[len(models)-1].Item

// Manually simulate building the `LastEvaluatedKey`.
lek := map[string]types.AttributeValue{
model.Item.AttrGSI1PartKey(): &types.AttributeValueMemberS{Value: last.GSI1PartKey},
model.Item.AttrGSI1SortKey(): &types.AttributeValueMemberS{Value: last.GSI1SortKey},
model.Item.AttrTablePartKey(): &types.AttributeValueMemberS{Value: last.TablePartKey},
model.Item.AttrTableSortKey(): &types.AttributeValueMemberS{Value: last.TableSortKey},
}

page, err = d.Pager.LastEvaluatedKey(lek)
if err != nil {
return nil, errors.Wrap(app.ErrInternal, fmt.Sprintf("last evaluated key: %s", err.Error()))
}
fmt.Println(page)

return models, nil
}

// The page should be enc and dec while using here otherwise passing a similar plain string would still yield a result :(
func (d DynamoDB) ListOrdersByProductID(ctx context.Context, pid, page string, limit int, asc bool) ([]*database.OrderProduct, error) {
model := database.OrderProduct{
ProductID: pid,
}
model.Build()

input := dynamodb.QueryInput{
TableName: aws.String(d.Table),
IndexName: aws.String(d.GlobalIndex1),
KeyConditionExpression: aws.String("#" + model.Item.AttrGSI1PartKey() + " = :gsi_pk"),
ExpressionAttributeNames: map[string]string{
"#" + model.Item.AttrGSI1PartKey(): model.Item.AttrGSI1PartKey(),
},
ExpressionAttributeValues: map[string]types.AttributeValue{
":gsi_pk": &types.AttributeValueMemberS{Value: model.Item.GSI1PartKey},
},
Limit: aws.Int32(int32(limit) + 1), // Workaround to say there is more pages to paginate
ScanIndexForward: aws.Bool(asc), // true == ASC
}

// Paginate by starting from where it was left off in the previous call.
if page != "" {
esk, err := d.Pager.ExclusiveStartKey(page)
if err != nil {
return nil, errors.Wrap(app.ErrInternal, fmt.Sprintf("exclusive start key: %s", err.Error()))
}
input.ExclusiveStartKey = esk
}

res, err := d.Client.Query(ctx, &input)
if err != nil {
return nil, errors.Wrap(app.ErrInternal, fmt.Sprintf("query: %s", err.Error()))
}

var models []*database.OrderProduct

if err := attributevalue.UnmarshalListOfMaps(res.Items, &models); err != nil {
return nil, errors.Wrap(app.ErrInternal, fmt.Sprintf("unmarshal list of maps: %s", err.Error()))
}

// There is no more pages to paginate.
total := len(models)
if total != limit+1 {
return models, nil
}

// There is more pages to paginate but take out the last item from the list
// before proceeding.
models = models[:total-1]

total = len(models)
if total == 0 {
return models, nil
}

// Get the last item which represents the current cursor location which where
// the next iteration will start from for the next call.
last := models[len(models)-1].Item

// Manually simulate building the `LastEvaluatedKey`.
lek := map[string]types.AttributeValue{
model.Item.AttrGSI1PartKey(): &types.AttributeValueMemberS{Value: last.GSI1PartKey},
model.Item.AttrGSI1SortKey(): &types.AttributeValueMemberS{Value: last.GSI1SortKey},
model.Item.AttrTablePartKey(): &types.AttributeValueMemberS{Value: last.TablePartKey},
model.Item.AttrTableSortKey(): &types.AttributeValueMemberS{Value: last.TableSortKey},
}

page, err = d.Pager.LastEvaluatedKey(lek)
if err != nil {
return nil, errors.Wrap(app.ErrInternal, fmt.Sprintf("last evaluated key: %s", err.Error()))
}
fmt.Println(page)

return models, nil
}

page.go


package storage

import (
"sync"

"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
)

type transcoder interface {
Register(source any)
Encode(source any) ([]byte, error)
Decode(target any, encoded []byte) error
}

type crypter interface {
Encrypt(plain []byte) (string, error)
Decrypt(encrypted string) ([]byte, error)
}

type Page struct {
Transcoder transcoder
Crypter crypter

once sync.Once
}

// LastEvaluatedKey creates what `ExclusiveStartKey()` will be using.
func (p *Page) LastEvaluatedKey(val map[string]types.AttributeValue) (string, error) {
p.once.Do(func() {
p.Transcoder.Register(&types.AttributeValueMemberS{})
})

enc, err := p.Transcoder.Encode(val)
if err != nil {
return "", err
}

return p.Crypter.Encrypt(enc)
}

// ExclusiveStartKey accepts what `LastEvaluatedKey()` has created previously.
func (p *Page) ExclusiveStartKey(val string) (map[string]types.AttributeValue, error) {
p.once.Do(func() {
p.Transcoder.Register(&types.AttributeValueMemberS{})
})

dec, err := p.Crypter.Decrypt(val)
if err != nil {
return nil, err
}

var res map[string]types.AttributeValue

if err := p.Transcoder.Decode(&res, dec); err != nil {
return nil, err
}

return res, nil
}

aes.go


package xcrypto

import (
"crypto/aes"
"encoding/base64"
"errors"
)

// AES helps encrypt and decrypte data. Encrypted output is always same for a
// given input due to use of deterministic encryption scheme. The `Key` is
// expected to be a AES-128 (128 bits = 16 bytes) in size.
type AES struct {
Key []byte
}

func (a AES) Encrypt(plain []byte) (string, error) {
block, err := aes.NewCipher(a.Key)
if err != nil {
return "", err
}

text := string(plain)
pad := aes.BlockSize - len(text)%aes.BlockSize
padded := text + string(make([]byte, pad))

cipher := make([]byte, len(padded))
for i := 0; i < len(padded); i += aes.BlockSize {
block.Encrypt(cipher[i:i+aes.BlockSize], []byte(padded[i:i+aes.BlockSize]))
}

return base64.URLEncoding.EncodeToString(cipher), nil
}

func (a AES) Decrypt(encrypted string) ([]byte, error) {
cipher, err := base64.URLEncoding.DecodeString(encrypted)
if err != nil {
return nil, err
}

if len(cipher)%aes.BlockSize != 0 {
return nil, errors.New("cipher is not a multiple of the block size")
}

block, err := aes.NewCipher(a.Key)
if err != nil {
return nil, err
}

text := make([]byte, len(cipher))
for i := 0; i < len(cipher); i += aes.BlockSize {
block.Decrypt(text[i:i+aes.BlockSize], cipher[i:i+aes.BlockSize])
}

pad := int(text[len(text)-1])

return text[:len(text)-pad], nil
}

random.go


package xcrypto

import "crypto/rand"

type size int

const (
// AES-128 = 128 bits = 16 bytes (8*2)
SecretAES128 size = 16
// AES-192 = 192 bits = 24 bytes (12*2)
SecretAES192 size = 24
// AES-256 = 256 bits = 32 bytes (16*2)
SecretAES256 size = 32
)

// Random hels generate a cryptographically secure output.
// Use `fmt.Printf("%x", key)` to print as string.
type Random struct {
Size size
}

func (r Random) Generate() ([]byte, error) {
res := make([]byte, r.Size)

if _, err := rand.Read(res); err != nil {
return nil, err
}

return res, nil
}

gob.go


package xtranscode

import (
"bytes"
"encoding/gob"
)

// GOB helps encode and decode data. It is more efficient than the JSON version.
type GOB struct{}

func (GOB) Register(source any) {
gob.Register(source)
}

func (GOB) Encode(source any) ([]byte, error) {
var buf bytes.Buffer

if err := gob.NewEncoder(&buf).Encode(source); err != nil {
return nil, err
}

return buf.Bytes(), nil
}

func (GOB) Decode(target any, encoded []byte) error {
return gob.NewDecoder(bytes.NewBuffer(encoded)).Decode(target)
}