Hello everyone!

We have been investing plenty of personal time and energy for many years to share our knowledge with you all. However, we now need your help to keep this blog running. All you have to do is just click one of the adverts on the site, otherwise it will sadly be taken down due to hosting etc. costs. Thank you.

This is a simple AWS DynamoDB CRUD example written in Golang. Just bear in mind, some files need improvement. There are hard-coded pieces, duplications so on. I had to keep this post as short as possible. Feel free to refactor it.


Structure


├── internal
│   ├── domain
│   │   └── error.go
│   ├── pkg
│   │   └── storage
│   │   ├── aws
│   │   │   ├── aws.go
│   │   │   └── user_storage.go
│   │   └── user_storer.go
│   └── user
│   ├── controller.go
│   └── models.go
├── main.go
└── migrations
└── users.json

Perquisites


First of all you need to create the users table with uuid field as partition key.


$ aws --profile localstack --endpoint-url http://localhost:4566 dynamodb create-table --cli-input-json file://migrations/users.json

# migrations/users.json

{
"TableName": "users",
"AttributeDefinitions": [
{
"AttributeName": "uuid",
"AttributeType": "S"
}
],
"KeySchema": [
{
"AttributeName": "uuid",
"KeyType": "HASH"
}
],
"ProvisionedThroughput": {
"ReadCapacityUnits": 5,
"WriteCapacityUnits": 5
}
}

Files


main.go


Never mind the endpoints because there shouldn't be suffixes like that!


package main

import (
"log"
"net/http"
"time"

"github.com/you/aws/internal/pkg/storage/aws"
"github.com/you/aws/internal/user"
)

func main() {
// Create a session instance.
ses, err := aws.New(aws.Config{
Address: "http://localhost:4566",
Region: "eu-west-1",
Profile: "localstack",
ID: "test",
Secret: "test",
})
if err != nil {
log.Fatalln(err)
}

// Instantiate HTTP app
usr := user.Controller{
Storage: aws.NewUserStorage(ses, time.Second*5),
}

// Instantiate HTTP router
rtr := http.NewServeMux()
rtr.HandleFunc("/api/v1/users/create", usr.Create)
rtr.HandleFunc("/api/v1/users/find", usr.Find)
rtr.HandleFunc("/api/v1/users/delete", usr.Delete)
rtr.HandleFunc("/api/v1/users/update", usr.Update)

// Start HTTP server
log.Fatalln(http.ListenAndServe(":8080", rtr))
}

controller.go


package user

import (
"encoding/json"
"log"
"net/http"

"github.com/you/aws/internal/domain"
"github.com/you/aws/internal/pkg/storage"

"github.com/google/uuid"
)

type Controller struct {
Storage storage.UserStorer
}

// POST /api/v1/users/create
func (c Controller) Create(w http.ResponseWriter, r *http.Request) {
var req User
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}

id := uuid.New().String()

err := c.Storage.Insert(r.Context(), storage.User{
UUID: id,
Name: req.Name,
Level: req.Level,
IsBlocked: req.IsBlocked,
CreatedAt: req.CreatedAt,
Roles: req.Roles,
})
if err != nil {
switch err {
case domain.ErrConflict:
w.WriteHeader(http.StatusConflict)
default:
w.WriteHeader(http.StatusInternalServerError)
}
return
}

w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(id))
}

// GET /api/v1/users/find?id={UUID}
func (c Controller) Find(w http.ResponseWriter, r *http.Request) {
res, err := c.Storage.Find(r.Context(), r.URL.Query().Get("id"))
if err != nil {
switch err {
case domain.ErrNotFound:
w.WriteHeader(http.StatusNotFound)
default:
w.WriteHeader(http.StatusInternalServerError)
}
return
}

user := User{
UUID: res.UUID,
Name: res.Name,
Level: res.Level,
IsBlocked: res.IsBlocked,
CreatedAt: res.CreatedAt,
Roles: res.Roles,
}

data, err := json.Marshal(user)
if err != nil {
log.Println(err)

w.WriteHeader(http.StatusInternalServerError)
}

w.Header().Set("Content-Type", "application/json; charset=utf-8")
_, _ = w.Write(data)
}

// DELETE /api/v1/users/delete?id={UUID}
func (c Controller) Delete(w http.ResponseWriter, r *http.Request) {
err := c.Storage.Delete(r.Context(), r.URL.Query().Get("id"))
if err != nil {
switch err {
case domain.ErrNotFound:
w.WriteHeader(http.StatusNotFound)
default:
w.WriteHeader(http.StatusInternalServerError)
}
return
}

w.WriteHeader(http.StatusNoContent)
}

// PATCH /api/v1/users/update?id={UUID}
func (c Controller) Update(w http.ResponseWriter, r *http.Request) {
var req User
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}

err := c.Storage.Update(r.Context(), storage.User{
UUID: r.URL.Query().Get("id"),
Name: req.Name,
Level: req.Level,
Roles: req.Roles,
})
if err != nil {
switch err {
case domain.ErrNotFound:
w.WriteHeader(http.StatusConflict)
default:
w.WriteHeader(http.StatusInternalServerError)
}
return
}

w.WriteHeader(http.StatusNoContent)
}

models.go


package user

import "time"

type User struct {
UUID string `json:"uuid"`
Name string `json:"name"`
Level int `json:"level"`
IsBlocked bool `json:"is_blocked"`
CreatedAt time.Time `json:"created_at"`
Roles []string `json:"roles"`
}

error.go


package domain

import "errors"

var (
ErrInternal = errors.New("internal")
ErrNotFound = errors.New("not found")
ErrConflict = errors.New("conflict")
)

user_storer.go


package storage

import (
"context"
"time"
)

type User struct {
UUID string `json:"uuid"`
Name string `json:"name"`
Level int `json:"level"`
IsBlocked bool `json:"is_blocked"`
CreatedAt time.Time `json:"created_at"`
Roles []string `json:"roles"`
}

type UserStorer interface {
Insert(ctx context.Context, user User) error
Find(ctx context.Context, uuid string) (User, error)
Delete(ctx context.Context, uuid string) error
Update(ctx context.Context, user User) error
}

user_storage.go


package aws

import (
"context"
"fmt"
"log"
"time"

"github.com/you/aws/internal/domain"
"github.com/you/aws/internal/pkg/storage"

"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/dynamodb"
"github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute"
)

var _ storage.UserStorer = UserStorage{}

type UserStorage struct {
timeout time.Duration
client *dynamodb.DynamoDB
}

func NewUserStorage(session *session.Session, timeout time.Duration) UserStorage {
return UserStorage{
timeout: timeout,
client: dynamodb.New(session),
}
}

func (u UserStorage) Insert(ctx context.Context, user storage.User) error {
ctx, cancel := context.WithTimeout(ctx, u.timeout)
defer cancel()

item, err := dynamodbattribute.MarshalMap(user)
if err != nil {
log.Println(err)
return domain.ErrInternal
}

input := &dynamodb.PutItemInput{
TableName: aws.String("users"),
Item: item,
ExpressionAttributeNames: map[string]*string{
"#uuid": aws.String("uuid"),
},
ConditionExpression: aws.String("attribute_not_exists(#uuid)"),
}

if _, err := u.client.PutItemWithContext(ctx, input); err != nil {
log.Println(err)

if _, ok := err.(*dynamodb.ConditionalCheckFailedException); ok {
return domain.ErrConflict
}

return domain.ErrInternal
}

return nil
}

func (u UserStorage) Find(ctx context.Context, uuid string) (storage.User, error) {
ctx, cancel := context.WithTimeout(ctx, u.timeout)
defer cancel()

input := &dynamodb.GetItemInput{
TableName: aws.String("users"),
Key: map[string]*dynamodb.AttributeValue{
"uuid": {S: aws.String(uuid)},
},
}

res, err := u.client.GetItemWithContext(ctx, input)
if err != nil {
log.Println(err)

return storage.User{}, domain.ErrInternal
}

if res.Item == nil {
return storage.User{}, domain.ErrNotFound
}

var user storage.User
if err := dynamodbattribute.UnmarshalMap(res.Item, &user); err != nil {
log.Println(err)

return storage.User{}, domain.ErrInternal
}

return user, nil
}

func (u UserStorage) Delete(ctx context.Context, uuid string) error {
ctx, cancel := context.WithTimeout(ctx, u.timeout)
defer cancel()

input := &dynamodb.DeleteItemInput{
TableName: aws.String("users"),
Key: map[string]*dynamodb.AttributeValue{
"uuid": {S: aws.String(uuid)},
},
}

if _, err := u.client.DeleteItemWithContext(ctx, input); err != nil {
log.Println(err)

return domain.ErrInternal
}

return nil
}

func (u UserStorage) Update(ctx context.Context, user storage.User) error {
ctx, cancel := context.WithTimeout(ctx, u.timeout)
defer cancel()

roles := make([]*dynamodb.AttributeValue, len(user.Roles))
for i, role := range user.Roles {
roles[i] = &dynamodb.AttributeValue{S: aws.String(role)}
}

input := &dynamodb.UpdateItemInput{
TableName: aws.String("users"),
Key: map[string]*dynamodb.AttributeValue{
"uuid": {S: aws.String(user.UUID)},
},
ExpressionAttributeNames: map[string]*string{
"#name": aws.String("name"),
"#level": aws.String("level"),
"#roles": aws.String("roles"),
},
ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{
":name": {S: aws.String(user.Name)},
":level": {N: aws.String(fmt.Sprint(user.Level))},
":roles": {L: roles},
},
UpdateExpression: aws.String("set #name = :name, #level = :level, #roles = :roles"),
ReturnValues: aws.String("UPDATED_NEW"),
}

if _, err := u.client.UpdateItemWithContext(ctx, input); err != nil {
log.Println(err)

return domain.ErrInternal
}

return nil
}

aws.go


package aws

import (
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
)

type Config struct {
Address string
Region string
Profile string
ID string
Secret string
}

func New(config Config) (*session.Session, error) {
return session.NewSessionWithOptions(
session.Options{
Config: aws.Config{
Credentials: credentials.NewStaticCredentials(config.ID, config.Secret, ""),
Region: aws.String(config.Region),
Endpoint: aws.String(config.Address),
S3ForcePathStyle: aws.Bool(true),
},
Profile: config.Profile,
},
)
}

Test


# Create

curl --location --request POST 'http://localhost:8080/api/v1/users/create' \
--data-raw '{
"name": "inanzzz",
"level": 3,
"is_blocked": false,
"created_at": "2020-01-31T23:59:00Z",
"roles": ["accounts", "admin"]
}'

# Find

curl --location --request GET 'http://localhost:8080/api/v1/users/find?id=80638f40-d248-49be-90ce-88d5b1b4ecd4'

# Delete

curl --location --request DELETE 'http://localhost:8080/api/v1/users/delete?id=80638f40-d248-49be-90ce-88d5b1b4ecd4'

# Update

curl --location --request PATCH 'http://localhost:8080/api/v1/users/update?id=347ac592-b024-4001-9d1b-925abe10c236' \
--data-raw '{
"name": "inanzzz",
"level": 1,
"roles": ["accounts", "admin"]
}'

References