27/09/2023 - AWS, GO
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.
- 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
# 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])
# 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])
├── 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
{
"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
}
}
]
}
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
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)
}
package app
import (
"errors"
)
var (
ErrInternal = errors.New("internal")
ErrResourceNotFound = errors.New("resource not found")
)
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)
}
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)
}
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)
}
package api
type CreateCustomer struct {
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
}
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"`
}
package api
type CreateProduct struct {
Name string `json:"name"`
Price float64 `json:"price"`
Tax float64 `json:"tax"`
}
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",
}
}
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"
}
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",
}
}
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",
}
}
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
}
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
}
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
}
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
}
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)
}